diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..ae17ee40c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +**/zz_gen_*.* linguist-generated +docs/data/zz_cli_help.toml linguist-generated diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a4d077e5a..ea3fd9a3a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,9 +7,9 @@ body: attributes: label: Welcome options: - - label: Yes, I'm using a binary release within 2 latest releases. + - label: Yes, I'm using a binary release within the two latest releases. required: true - - label: Yes, I've searched similar issues on GitHub and didn't find any. + - label: Yes, I've searched for similar issues on GitHub and didn't find any. required: true - label: Yes, I've included all information below (version, config, etc). required: true @@ -35,6 +35,7 @@ body: attributes: label: How do you use lego? options: + - I don't know - Library - Binary - Docker image @@ -44,6 +45,8 @@ body: - Through Bitnami - Through 1Panel - Through Zoraxy + - Through Certimate + - go install - Other validations: required: true @@ -64,8 +67,9 @@ body: - type: textarea id: version attributes: - label: Version of lego + label: Effective version of lego description: |- + `latest` or `dev` are not effective versions. ```console $ lego --version ``` diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index b4e264177..7f6793167 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -6,7 +6,7 @@ body: attributes: label: Welcome options: - - label: Yes, I've searched similar issues on GitHub and didn't find any. + - label: Yes, I've searched for similar issues on GitHub and didn't find any. required: true - type: dropdown @@ -14,6 +14,7 @@ body: attributes: label: How do you use lego? options: + - I don't know - Library - Binary - Docker image @@ -23,10 +24,20 @@ body: - Through Bitnami - Through 1Panel - Through Zoraxy + - Through Certimate + - go install - Other validations: required: true + - type: input + id: version + attributes: + label: Effective version of lego + description: "`latest` or `dev` are not effective versions." + validations: + required: true + - type: textarea id: description attributes: diff --git a/.github/ISSUE_TEMPLATE/new_dns_provider.yml b/.github/ISSUE_TEMPLATE/new_dns_provider.yml index 274983636..b319bc287 100644 --- a/.github/ISSUE_TEMPLATE/new_dns_provider.yml +++ b/.github/ISSUE_TEMPLATE/new_dns_provider.yml @@ -8,15 +8,21 @@ body: attributes: label: Welcome options: - - label: Yes, I've searched similar issues on GitHub and didn't find any. + - label: Yes, I've searched for similar issues on GitHub and didn't find any. required: true - label: Yes, the DNS provider exposes a public API. required: true - label: Yes, I know that the lego maintainers don't have an account in all DNS providers in the world. required: true + + - type: checkboxes + id: pr + attributes: + label: Implementation + options: - label: Yes, I'm able to create a pull request and be able to maintain the implementation. required: false - - label: Yes, I'm able to test an implementation if someone creates a pull request to add the support of this DNS provider. + - label: Yes, I can test an implementation with the help of the maintainers if someone creates a pull request. required: false - type: dropdown @@ -24,6 +30,7 @@ body: attributes: label: How do you use lego? options: + - I don't know - Library - Binary - Docker image @@ -33,10 +40,23 @@ body: - Through Bitnami - Through 1Panel - Through Zoraxy + - Through Certimate + - go install - Other validations: required: true + - type: dropdown + id: profile + attributes: + label: Who are you? + options: + - A customer of this DNS provider + - An employee of this DNS provider + - Other (please explain) + validations: + required: true + - type: input id: provider-link attributes: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..795320a8d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ + diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 46f7f6730..4f9d444fc 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -12,20 +12,16 @@ jobs: runs-on: ubuntu-latest env: GO_VERSION: stable - HUGO_VERSION: 0.131.0 + HUGO_VERSION: 0.148.2 CGO_ENABLED: 0 steps: - # https://github.com/marketplace/actions/checkout - - name: Check out code - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - # https://github.com/marketplace/actions/setup-go-environment - - name: Set up Go ${{ env.GO_VERSION }} - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} diff --git a/.github/workflows/go-cross.yml b/.github/workflows/go-cross.yml index 30ec652a2..9dee85035 100644 --- a/.github/workflows/go-cross.yml +++ b/.github/workflows/go-cross.yml @@ -20,13 +20,8 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - # https://github.com/marketplace/actions/checkout - - name: Checkout code - uses: actions/checkout@v4 - - # https://github.com/marketplace/actions/setup-go-environment - - name: Set up Go ${{ matrix.go-version }} - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d7404a6b8..33ca106cc 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -13,54 +13,44 @@ jobs: runs-on: ubuntu-latest env: GO_VERSION: stable - GOLANGCI_LINT_VERSION: v1.62.0 - HUGO_VERSION: 0.131.0 + GOLANGCI_LINT_VERSION: v2.10 + HUGO_VERSION: 0.148.2 CGO_ENABLED: 0 LEGO_E2E_TESTS: CI MEMCACHED_HOSTS: localhost:11211 steps: - # https://github.com/marketplace/actions/checkout - - name: Check out code - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - # https://github.com/marketplace/actions/setup-go-environment - - name: Set up Go ${{ env.GO_VERSION }} - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} - name: Check and get dependencies run: | - go mod tidy - git diff --exit-code go.mod - git diff --exit-code go.sum + go mod tidy --diff - # https://golangci-lint.run/usage/install#other-ci - - name: Install golangci-lint ${{ env.GOLANGCI_LINT_VERSION }} + - name: Generate and Check generated elements run: | - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_LINT_VERSION} - golangci-lint --version + make generate-dns + git diff --exit-code + + - uses: golangci/golangci-lint-action@v9 + with: + version: ${{ env.GOLANGCI_LINT_VERSION }} + install-only: true - name: Install Pebble - run: go install github.com/letsencrypt/pebble/v2/cmd/pebble@3fe019bbc0a41ed16e2fee31592bb91751acaa47 + run: go install github.com/letsencrypt/pebble/v2/cmd/pebble@v2.9.0 - name: Install challtestsrv - run: go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@3fe019bbc0a41ed16e2fee31592bb91751acaa47 + run: go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@v2.9.0 - name: Set up a Memcached server - uses: niden/actions-memcached@v7 - - - name: Setup /etc/hosts - run: | - echo "127.0.0.1 acme.wtf" | sudo tee -a /etc/hosts - echo "127.0.0.1 lego.wtf" | sudo tee -a /etc/hosts - echo "127.0.0.1 acme.lego.wtf" | sudo tee -a /etc/hosts - echo "127.0.0.1 légô.wtf" | sudo tee -a /etc/hosts - echo "127.0.0.1 xn--lg-bja9b.wtf" | sudo tee -a /etc/hosts + run: docker run -d --rm -p 11211:11211 memcached:1.6-alpine - name: Make run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67b18b770..6a0d3b703 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,11 @@ on: tags: - v* +permissions: + # Allow the workflow to write attestations. + id-token: write + attestations: write + jobs: release: @@ -37,13 +42,11 @@ jobs: docker-images: true swap-storage: false - - name: Check out code - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Set up Go ${{ env.GO_VERSION }} - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} @@ -64,10 +67,21 @@ jobs: # https://goreleaser.com/ci/actions/ - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v5 + id: goreleaser + uses: goreleaser/goreleaser-action@v6 with: - version: latest + version: v2.13.0 args: release -p 1 --clean --timeout=90m env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN_REPO }} SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + AUR_KEY: ${{ secrets.AUR_KEY }} + + - uses: actions/attest-build-provenance@v3 + with: + subject-checksums: ./dist/lego_${{ fromJSON(steps.goreleaser.outputs.metadata).version }}_checksums.txt + github-token: ${{ secrets.GH_TOKEN_REPO }} + - uses: actions/attest-build-provenance@v3 + with: + subject-checksums: ./dist/digests.txt + github-token: ${{ secrets.GH_TOKEN_REPO }} diff --git a/.golangci.yml b/.golangci.yml index b3383969a..b6ab51ccc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,265 +1,284 @@ +version: "2" + +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + settings: + gofumpt: + extra-rules: true + gofmt: + rewrite-rules: + - pattern: 'interface{}' + replacement: 'any' + linters: - enable-all: true + default: all disable: + - wsl # Deprecated + - bodyclose + - canonicalheader + - contextcheck - cyclop # duplicate of gocyclo - - sqlclosecheck # not relevant (SQL) - - rowserrcheck # not relevant (SQL) - - lll - - gosec - dupl # not relevant - - prealloc # too many false-positive - - bodyclose # too many false-positive - - mnd - - testpackage # not relevant - - tparallel # not relevant - - paralleltest # not relevant - - nestif # too many false-positive - - wrapcheck - err113 # not relevant - - nlreturn # not relevant - - wsl # not relevant + - errchkjson + - errname - exhaustive # not relevant - exhaustruct # not relevant - - makezero # not relevant - forbidigo - - varnamelen # not relevant - - nilnil # not relevant - - ireturn # not relevant - - contextcheck # too many false-positive - - tenv # we already have a test "framework" to handle env vars - - noctx - forcetypeassert - - tagliatelle - - errname - - errchkjson - - nonamedreturns + - gosec + - gosmopolitan # not relevant + - ireturn # not relevant + - lll + - makezero # not relevant + - mnd - musttag # false-positive https://github.com/junk1tm/musttag/issues/17 - - gosmopolitan # not relevant - - exportloopref # Useless with go1.22 - - canonicalheader # Can create side effects in the context of API clients - - usestdlibvars # false-positive https://github.com/sashamelentyev/usestdlibvars/issues/96 + - nestif # too many false-positive + - nilnil # not relevant + - nlreturn # not relevant + - noctx + - noinlineerr # too strict + - nonamedreturns + - paralleltest # not relevant + - prealloc # too many false-positive + - rowserrcheck # not relevant (SQL) + - sqlclosecheck # not relevant (SQL) + - tagliatelle + - testpackage # not relevant + - tparallel # not relevant + - varnamelen # not relevant + - wrapcheck -linters-settings: - govet: - enable: - - shadow - gocyclo: - min-complexity: 12 - goconst: - min-len: 3 - min-occurrences: 3 - funlen: - lines: -1 - statements: 50 - misspell: - locale: US - ignore-words: - - internetbs - depguard: + settings: + depguard: + rules: + main: + deny: + - pkg: github.com/instana/testify + desc: not allowed + - pkg: github.com/pkg/errors + desc: Should be replaced by standard lib errors package + funlen: + lines: -1 + statements: 50 + goconst: + min-len: 3 + min-occurrences: 3 + gocritic: + disabled-checks: + - paramTypeCombine # already handle by gofumpt.extra-rules + - whyNoLint # already handle by nonolint + - unnamedResult + - hugeParam + - sloppyReassign + - rangeValCopy + - octalLiteral + - ptrToRefParam + - appendAssign + - ruleguard + - httpNoBody + - exposedSyncMutex + enabled-tags: + - diagnostic + - style + - performance + gocyclo: + min-complexity: 12 + godox: + keywords: + - FIXME + govet: + disable: + - fieldalignment + enable-all: true + settings: + printf: + funcs: + - Print + - Printf + - Warn + - Warnf + - Fatal + - Fatalf + misspell: + locale: US + ignore-rules: + - internetbs + perfsprint: + err-error: true + errorf: true + sprintf1: true + strconcat: false + revive: + rules: + - name: struct-tag + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + disabled: true + - name: if-return + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: package-comments + disabled: true + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unused-parameter + disabled: true + - name: unreachable-code + - name: redefines-builtin-id + tagalign: + align: false + order: + - xml + - json + - yaml + - yml + - toml + - mapstructure + - url + testifylint: + disable: + - require-error + - go-require + usetesting: + os-setenv: false # we already have a test "framework" to handle env vars + funcorder: + struct-method: false + + exclusions: + warn-unused: true + presets: + - comments + - std-error-handling + paths: + # Those elements are related to code borrowed from the official HuaweiCloud API client. + - providers/dns/huaweicloud/internal rules: - main: - deny: - - pkg: "github.com/instana/testify" - desc: not allowed - - pkg: "github.com/pkg/errors" - desc: Should be replaced by standard lib errors package - tagalign: - align: false - order: - - xml - - json - - yaml - - yml - - toml - - mapstructure - - url - godox: - keywords: - - FIXME - gocritic: - enabled-tags: - - diagnostic - - style - - performance - disabled-checks: - - paramTypeCombine # already handle by gofumpt.extra-rules - - whyNoLint # already handle by nonolint - - unnamedResult - - hugeParam - - sloppyReassign - - rangeValCopy - - octalLiteral - - ptrToRefParam - - appendAssign - - ruleguard - - httpNoBody - - exposedSyncMutex - revive: - rules: - - name: struct-tag - - name: blank-imports - - name: context-as-argument - - name: context-keys-type - - name: dot-imports - - name: error-return - - name: error-strings - - name: error-naming - - name: exported - disabled: true - - name: if-return - - name: increment-decrement - - name: var-naming - - name: var-declaration - - name: package-comments - disabled: true - - name: range - - name: receiver-naming - - name: time-naming - - name: unexported-return - - name: indent-error-flow - - name: errorf - - name: empty-block - - name: superfluous-else - - name: unused-parameter - disabled: true - - name: unreachable-code - - name: redefines-builtin-id - testifylint: - disable: - - require-error - - go-require - perfsprint: - err-error: true - errorf: true - sprintf1: true - strconcat: false - -run: - timeout: 10m - -output: - show-stats: true - sort-results: true - sort-order: - - linter - - file + - path: (.+)_test.go + linters: + - funlen + - goconst + - maintidx + - path: (.+)_test.go + text: Error return value of `fmt.Fprintln` is not checked + linters: + - errcheck + - text: "var-naming: avoid meaningless package names" + linters: + - revive + - text: "var-naming: avoid package names that conflict with Go standard library package names" + linters: + - revive + - path: certcrypto/crypto.go + text: (tlsFeatureExtensionOID|ocspMustStapleFeature) is a global variable + linters: + - gochecknoglobals + - path: challenge/dns01/nameserver.go + text: (defaultNameservers|recursiveNameservers|fqdnSoaCache|muFqdnSoaCache) is a global variable + linters: + - gochecknoglobals + - path: challenge/dns01/nameserver_.+.go + text: dnsTimeout is a global variable + linters: + - gochecknoglobals + - path: challenge/dns01/precheck.go + text: defaultNameserverPort is a global variable + linters: + - gochecknoglobals + - path: challenge/http01/domain_matcher.go + text: cyclomatic complexity \d+ of func `parseForwardedHeader` is high + linters: + - gocyclo + - path: challenge/http01/domain_matcher.go + text: Function 'parseForwardedHeader' has too many statements + linters: + - funlen + - path: challenge/tlsalpn01/tls_alpn_challenge.go + text: idPeAcmeIdentifierV1 is a global variable + linters: + - gochecknoglobals + - path: log/logger.go + text: Logger is a global variable + linters: + - gochecknoglobals + - path: e2e/(dnschallenge/)?[\d\w]+_test.go + text: load is a global variable + linters: + - gochecknoglobals + - path: providers/(dns|http)/([\d\w]+/)*[\d\w]+_test.go + text: envTest is a global variable + linters: + - gochecknoglobals + - path: providers/dns/namecheap/namecheap_test.go + text: testCases is a global variable + linters: + - gochecknoglobals + - path: providers/dns/namecheap/transport.go + text: (envProxyOnce|envProxyFuncValue) is a global variable + linters: + - gochecknoglobals + - path: providers/dns/acmedns/mock_test.go + text: egTestAccount is a global variable + linters: + - gochecknoglobals + - path: providers/http/memcached/memcached_test.go + text: memcachedHosts is a global variable + linters: + - gochecknoglobals + - path: providers/dns/checkdomain/internal/types.go + text: '`payed` is a misspelling of `paid`' + linters: + - misspell + - path: platform/tester/env_test.go + linters: + - thelper + - path: providers/dns/oraclecloud/oraclecloud_test.go + text: 'SA1019: x509.EncryptPEMBlock has been deprecated since Go 1.16' + linters: + - staticcheck + - path: providers/dns/sakuracloud/wrapper.go + text: mu is a global variable + linters: + - gochecknoglobals + - path: cmd/cmd_renew.go + text: cyclomatic complexity \d+ of func `(renewForDomains|renewForCSR)` is high + linters: + - gocyclo + - path: cmd/cmd_renew.go + text: Function 'renewForDomains' has too many statements + linters: + - funlen + - path: providers/dns/cpanel/cpanel.go + text: cyclomatic complexity 13 of func `\(\*DNSProvider\)\.CleanUp` is high + linters: + - gocyclo + - path: providers/dns/manual/manual.go + text: 'SA1019: dns01.DNSProviderManual is deprecated' + linters: + - staticcheck + # Those elements have been replaced by non-exposed structures. + - path: providers/dns/linode/linode_test.go + text: 'SA1019: linodego\.(DomainsPagedResponse|DomainRecordsPagedResponse) is deprecated' + linters: + - staticcheck issues: - exclude-generated: strict - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 - exclude: - - 'Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked' - - 'exported (type|method|function) (.+) should have comment or be unexported' - - 'ST1000: at least one file in a package should have a package comment' - exclude-rules: - - path: (.+)_test.go - linters: - - funlen - - goconst - - maintidx - - path: (.+)_test.go - text: 'Error return value of `fmt.Fprintln` is not checked' - linters: - - errcheck - - path: providers/dns/dns_providers.go - linters: - - gocyclo - - path: certcrypto/crypto.go - text: '(tlsFeatureExtensionOID|ocspMustStapleFeature) is a global variable' - linters: - - gochecknoglobals - - path: challenge/dns01/nameserver.go - text: '(defaultNameservers|recursiveNameservers|fqdnSoaCache|muFqdnSoaCache) is a global variable' - linters: - - gochecknoglobals - - path: challenge/dns01/nameserver_.+.go - text: 'dnsTimeout is a global variable' - linters: - - gochecknoglobals - - path: challenge/dns01/nameserver_test.go - text: 'findXByFqdnTestCases is a global variable' - linters: - - gochecknoglobals - - path: challenge/http01/domain_matcher.go - text: 'string `Host` has \d occurrences, make it a constant' - linters: - - goconst - - path: challenge/http01/domain_matcher.go - text: 'cyclomatic complexity \d+ of func `parseForwardedHeader` is high' - linters: - - gocyclo - - path: challenge/http01/domain_matcher.go - text: "Function 'parseForwardedHeader' has too many statements" - linters: - - funlen - - path: challenge/tlsalpn01/tls_alpn_challenge.go - text: 'idPeAcmeIdentifierV1 is a global variable' - linters: - - gochecknoglobals - - path: log/logger.go - text: 'Logger is a global variable' - linters: - - gochecknoglobals - - path: 'e2e/(dnschallenge/)?[\d\w]+_test.go' - text: load is a global variable - linters: - - gochecknoglobals - - path: 'providers/dns/([\d\w]+/)*[\d\w]+_test.go' - text: 'envTest is a global variable' - linters: - - gochecknoglobals - - path: 'providers/http/([\d\w]+/)*[\d\w]+_test.go' - text: 'envTest is a global variable' - linters: - - gochecknoglobals - - path: providers/dns/namecheap/namecheap_test.go - text: 'testCases is a global variable' - linters: - - gochecknoglobals - - path: providers/dns/acmedns/acmedns_test.go - text: 'egTestAccount is a global variable' - linters: - - gochecknoglobals - - path: providers/http/memcached/memcached_test.go - text: 'memcachedHosts is a global variable' - linters: - - gochecknoglobals - - path: cmd/zz_gen_cmd_dnshelp.go - linters: - - gocyclo - - funlen - - path: providers/dns/checkdomain/internal/types.go - text: '`payed` is a misspelling of `paid`' - linters: - - misspell - - path: platform/tester/env_test.go - linters: - - thelper - - path: providers/dns/oraclecloud/oraclecloud_test.go - text: 'SA1019: x509.EncryptPEMBlock has been deprecated since Go 1.16' - linters: - - staticcheck - - path: providers/dns/sakuracloud/wrapper.go - text: 'mu is a global variable' - linters: - - gochecknoglobals - - path: cmd/cmd_renew.go - text: 'cyclomatic complexity \d+ of func `(renewForDomains|renewForCSR)` is high' - linters: - - gocyclo - - path: providers/dns/cpanel/cpanel.go - text: 'cyclomatic complexity 13 of func `\(\*DNSProvider\)\.CleanUp` is high' - linters: - - gocyclo - - path: providers/dns/servercow/internal/types.go - text: 'the methods of "Value" use pointer receiver and non-pointer receiver.' - linters: - - recvcheck - - # Those elements have been replaced by non-exposed structures. - - path: providers/dns/linode/linode_test.go - linters: - - staticcheck - text: "SA1019: linodego\\.(DomainsPagedResponse|DomainRecordsPagedResponse) is deprecated" diff --git a/.goreleaser.yml b/.goreleaser.yml index 8279b19b4..c358f8a38 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -5,7 +5,7 @@ project_name: lego builds: - binary: lego - main: ./cmd/lego/main.go + main: ./cmd/lego/ env: - CGO_ENABLED=0 flags: @@ -42,6 +42,10 @@ builds: goarch: 386 - goos: openbsd goarch: arm + # Deprecated in go1.25, Removed in go1.26 + # https://go.dev/doc/go1.25#windows + - goos: windows + goarch: arm changelog: sort: asc @@ -51,125 +55,98 @@ changelog: - '(?i)^Detach v[\d|.]+' - '(?i)^Prepare release v[\d|.]+' +release: + skip_upload: false + github: + owner: 'go-acme' + name: 'lego' + header: | + lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️ + + Everybody thinks that the others will donate, but in the end, nobody does. + + So if you think that lego is worth it, please consider [donating](https://donate.ldez.dev). + + For key updates, see the [changelog](https://github.com/go-acme/lego/blob/HEAD/CHANGELOG.md#v{{ .Major }}{{ .Minor }}{{ .Patch }}). + archives: - id: lego name_template: '{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}' - format: tar.gz + formats: ['tar.gz'] format_overrides: - goos: windows - format: zip + formats: ['zip'] files: - LICENSE - CHANGELOG.md -docker_manifests: - - name_template: 'goacme/lego:{{ .Tag }}' - image_templates: - - 'goacme/lego:{{ .Tag }}-amd64' - - 'goacme/lego:{{ .Tag }}-arm64' - - 'goacme/lego:{{ .Tag }}-armv7' - - name_template: 'goacme/lego:latest' - image_templates: - - 'goacme/lego:{{ .Tag }}-amd64' - - 'goacme/lego:{{ .Tag }}-arm64' - - 'goacme/lego:{{ .Tag }}-armv7' - - name_template: 'goacme/lego:v{{ .Major }}.{{ .Minor }}' - image_templates: - - 'goacme/lego:v{{ .Major }}.{{ .Minor }}-amd64' - - 'goacme/lego:v{{ .Major }}.{{ .Minor }}-arm64' - - 'goacme/lego:v{{ .Major }}.{{ .Minor }}-armv7' - -dockers: - - use: buildx - goos: linux - goarch: amd64 +dockers_v2: + - images: + - 'goacme/lego' dockerfile: buildx.Dockerfile - image_templates: - - 'goacme/lego:latest-amd64' - - 'goacme/lego:{{ .Tag }}-amd64' - - 'goacme/lego:v{{ .Major }}.{{ .Minor }}-amd64' - build_flag_templates: - - '--pull' + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm/v7 + tags: + - 'latest' + - 'v{{ .Major }}' + - 'v{{ .Major }}.{{ .Minor }}' + - '{{ .Tag }}' + labels: # https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys - - '--label=org.opencontainers.image.title={{.ProjectName}}' - - '--label=org.opencontainers.image.description=Lets Encrypt/ACME client and library written in Go' - - '--label=org.opencontainers.image.source={{.GitURL}}' - - '--label=org.opencontainers.image.url={{.GitURL}}' - - '--label=org.opencontainers.image.documentation=https://go-acme.github.io/lego' - - '--label=org.opencontainers.image.created={{.Date}}' - - '--label=org.opencontainers.image.revision={{.FullCommit}}' - - '--label=org.opencontainers.image.version={{.Version}}' - - '--platform=linux/amd64' + 'org.opencontainers.image.title': '{{.ProjectName}}' + 'org.opencontainers.image.description': 'Lets Encrypt/ACME client and library written in Go' + 'org.opencontainers.image.source': '{{.GitURL}}' + 'org.opencontainers.image.url': '{{.GitURL}}' + 'org.opencontainers.image.documentation': 'https://go-acme.github.io/lego' + 'org.opencontainers.image.created': '{{.Date}}' + 'org.opencontainers.image.revision': '{{.FullCommit}}' + 'org.opencontainers.image.version': '{{.Version}}' - - use: buildx - goos: linux - goarch: arm64 - dockerfile: buildx.Dockerfile - image_templates: - - 'goacme/lego:latest-arm64' - - 'goacme/lego:{{ .Tag }}-arm64' - - 'goacme/lego:v{{ .Major }}.{{ .Minor }}-arm64' - build_flag_templates: - - '--pull' - # https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys - - '--label=org.opencontainers.image.title={{.ProjectName}}' - - '--label=org.opencontainers.image.description=Lets Encrypt/ACME client and library written in Go' - - '--label=org.opencontainers.image.source={{.GitURL}}' - - '--label=org.opencontainers.image.url={{.GitURL}}' - - '--label=org.opencontainers.image.documentation=https://go-acme.github.io/lego' - - '--label=org.opencontainers.image.created={{.Date}}' - - '--label=org.opencontainers.image.revision={{.FullCommit}}' - - '--label=org.opencontainers.image.version={{.Version}}' - - '--platform=linux/arm64' - - - use: buildx - goos: linux - goarch: arm - goarm: '7' - dockerfile: buildx.Dockerfile - image_templates: - - 'goacme/lego:latest-armv7' - - 'goacme/lego:{{ .Tag }}-armv7' - - 'goacme/lego:v{{ .Major }}.{{ .Minor }}-armv7' - build_flag_templates: - - '--pull' - # https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys - - '--label=org.opencontainers.image.title={{.ProjectName}}' - - '--label=org.opencontainers.image.description=Lets Encrypt/ACME client and library written in Go' - - '--label=org.opencontainers.image.source={{.GitURL}}' - - '--label=org.opencontainers.image.url={{.GitURL}}' - - '--label=org.opencontainers.image.documentation=https://go-acme.github.io/lego' - - '--label=org.opencontainers.image.created={{.Date}}' - - '--label=org.opencontainers.image.revision={{.FullCommit}}' - - '--label=org.opencontainers.image.version={{.Version}}' - - '--platform=linux/arm/v7' - -# Disabled because https://github.com/go-acme/lego/pull/2134#issuecomment-2135293270 snapcrafts: - - name: lego - disable: true + - name_template: "{{ .ProjectName }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" + disable: false + publish: true grade: stable confinement: strict license: MIT base: core22 - publish: true summary: Lego is a Let's Encrypt/ACME client. description: | Lego is a Let's Encrypt/ACME client written in Go. - + The lego snap makes it easy to install and use Lego on any Linux distribution that supports snaps. - + Usage: * `sudo snap install lego` * `sudo lego --email="you@example.com" --domains="example.com" --server=https://acme-staging-v02.api.letsencrypt.org/directory --http --http.port :8080 run - - channel_templates: - - edge - apps: lego: - command: bin/lego + command: lego environment: LEGO_PATH: /var/snap/lego/common/.lego plugs: - network-bind + +aurs: + - description: "Let s Encrypt client and ACME library written in Go" + skip_upload: false + homepage: https://go-acme.github.io/lego/ + name: 'lego-bin' + provides: + - lego + maintainers: + - "Fernandez Ludovic " + license: APACHE + private_key: "{{ .Env.AUR_KEY }}" + git_url: "ssh://aur@aur.archlinux.org/lego-bin.git" + commit_author: + name: ldez + email: ldez@users.noreply.github.com + package: |- + # Bin + install -Dm755 "./lego" "${pkgdir}/usr/bin/lego" + + # License + install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/lego/LICENSE" diff --git a/CHANGELOG.md b/CHANGELOG.md index 59a7cb6e1..ae73f70f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,407 @@ # Changelog -## [v4.20.0] - 2024-11-11 +lego is an independent, free, open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️ + +Everybody thinks that the others will donate, but in the end, nobody does. + +So if you think that lego is worth it, please consider [donating](https://donate.ldez.dev). + +## v4.32.0 + +- Release date: 2026-02-19 +- Tag: [v4.32.0](https://github.com/go-acme/lego/releases/tag/v4.32.0) + +### Added + +- **[dnsprovider]** Add DNS provider for ArtFiles +- **[dnsprovider]** Add DNS provider for Leaseweb +- **[dnsprovider]** Add DNS provider for FusionLayer NameSurfer +- **[dnsprovider]** Add DNS provider for DDNSS +- **[dnsprovider]** Add DNS provider for Bluecat v2 +- **[dnsprovider]** Add DNS provider for TodayNIC/时代互联 +- **[dnsprovider]** Add DNS provider for DNSExit +- **[dnsprovider]** alidns: add line record option + +### Changed + +- **[dnsprovider]** azure: reinforces deprecation +- **[dnsprovider]** allinkl: detect zone through API + +### Fixed + +- **[ari]** fix: implement parsing for Retry-After header according to RFC 7231 +- **[dnsprovider]** namesurfer: fix updateDNSHost +- **[dnsprovider]** timewebcloud: fix subdomain support +- **[dnsprovider]** fix: deduplicate authz for DNS01 challenge +- **[lib,cli]** fix: use IPs to define the main domain +- **[lib]** fix: preserve domain order + +## v4.31.0 + +- Release date: 2026-01-08 +- Tag: [v4.31.0](https://github.com/go-acme/lego/releases/tag/v4.31.0) + +### Added + +- **[dnsprovider]** Add DNS provider for ISPConfig +- **[dnsprovider]** Add DNS Provider for ISPConfig (DDNS Module) +- **[dnsprovider]** Add DNS provider for Alwaysdata +- **[dnsprovider]** Add DNS provider for JDCloud +- **[dnsprovider]** Add DNS provider for 35.com/三五互联 +- **[dnsprovider]** f5xc: add an option to configure the domain of the server + +### Changed + +- **[lib]** feat: improve ACME error types +- **[dnsprovider,cname]** namedotcom: follow CNAME + +### Fixed + +- **[dnsprovider]** hetzner: fix compatibility with _FILE suffix +- **[dnsprovider]** gandiv5: fix API Key header + +## v4.30.1 + +- Release date: 2025-12-16 +- Tag: [v4.30.1](https://github.com/go-acme/lego/releases/tag/v4.30.1) + +Due to an error related to `aliyun/credentials-go`, some artifacts of the v4.30.0 release have not been published. + +This release contains the same things as v4.30.0. + +## v4.30.0 + +- Release date: 2025-12-16 +- Tag: [v4.30.0](https://github.com/go-acme/lego/releases/tag/v4.30.0) + +### Added + +- **[dnsprovider]** Add DNS provider for Ionos Cloud +- **[dnsprovider]** Add DNS provider for Virtualname +- **[dnsprovider]** Add DNS Provider for Neodigit +- **[dnsprovider]** Add DNS provider for Syse.no +- **[dnsprovider]** Add DNS provider for Gravity +- **[dnsprovider]** Add DNS provider for hosting.nl + +### Changed + +- **[cli]** feat: remove email requirement + +### Fixed + +- **[dnsprovider]** autodns: use the right response structure + +## v4.29.0 + +- Release date: 2025-11-29 +- Tag: [v4.29.0](https://github.com/go-acme/lego/releases/tag/v4.29.0) + +### Added + +- **[dnsprovider]** Add DNS provider for United-Domains +- **[dnsprovider]** Add DNS provider for Gigahost.no +- **[dnsprovider]** Add DNS provider for EdgeCenter +- **[dnsprovider]** Add DNS provider for AlibabaCloud ESA +- **[dnsprovider]** edgeone: add zones mapping +- **[dnsprovider]** namecheap: add experimental proxy support + +### Changed + +- **[dnsprovider]** gandiv5: update base API URL + +### Fixed + +- **[dnsprovider]** hetzner: use int64 for IDs +- **[dnsprovider]** baiducloud: pagination and TTL +- **[dnsprovider]** inwx: fix API breaking changes with record IDs + +## v4.28.1 + +- Release date: 2025-11-06 +- Tag: [v4.28.1](https://github.com/go-acme/lego/releases/tag/v4.28.1) + +### Fixed + +- **[cli]** fix: skip nil response + +## v4.28.0 + +- Release date: 2025-10-31 +- Tag: [v4.28.0](https://github.com/go-acme/lego/releases/tag/v4.28.0) + +### Added + +- **[dnsprovider]** Add DNS provider for Anexia +- **[dnsprovider]** Add DNS provider for webnames.ca +- **[dnsprovider]** webnames: rename to webnamesru to avoid ambiguity with webnamesca + +### Changed + +- **[dnsprovider,log]** hetzner: add deprecation logs +- **[dnsprovider]** iwantmyname: provider deprecation +- **[cli]** improve retryable HTTP client error handling + +### Fixed + +- **[dnsprovider]** hostinger: fix record update + +## v4.27.0 + +- Release date: 2025-10-17 +- Tag: [v4.27.0](https://github.com/go-acme/lego/releases/tag/v4.27.0) + +### Added + +- **[dnsprovider]** Add DNS provider for Octenium +- **[dnsprovider]** Add DNS provider for Hostinger +- **[dnsprovider]** Add DNS provider for Beget.com + +### Changed + +- **[cli]** support `--private-key` with a PKCS#8 keypair +- **[dnsprovider]** hetzner: update to new API +- **[dnsprovider]** otc: adds option to use private zone + +### Fixed + +- **[lib]** fix: deduplicate order identifiers + +## v4.26.0 + +- Release date: 2025-09-13 +- Tag: [v4.26.0](https://github.com/go-acme/lego/releases/tag/v4.26.0) + +### Added + +- **[dnsprovider]** Add DNS provider for KeyHelp +- **[dnsprovider]** Add DNS provider for Binary Lane +- **[dnsprovider]** Add DNS provider for Tencent EdgeOne +- **[dnsprovider]** azuredns: pipeline credential support +- **[dnsprovider]** oraclecloud: handle instance_principal authentication + +### Changed + +- **[dnsprovider]** oraclecloud: add env var aliases +- **[dnsprovider]** simply: update to API v2 +- **[lib,cli]** EAB: fallback to base64.URLEncoding + +### Fixed + +- **[dnsprovider]** selectelv2: add missing options + +## v4.25.2 + +- Release date: 2025-08-06 +- Tag: [v4.25.2](https://github.com/go-acme/lego/releases/tag/v4.25.2) + +### Changed + +- **[cli,log]** log when dynamic renew date not yet reached + +### Fixed + +- **[cli]** fix: remove wrong env var +- **[lib,cli]** fix: enforce HTTPS to the ACME server + +## v4.25.1 + +- Release date: 2025-07-21 +- Tag: [v4.25.1](https://github.com/go-acme/lego/releases/tag/v4.25.1) + +### Fixed + +- **[cli]** fix: wrong CLI flag type + +## v4.25.0 + +- Release date: 2025-07-21 +- Tag: [v4.25.0](https://github.com/go-acme/lego/releases/tag/v4.25.0) + +The binary size of this release is about ~50% smaller compared to previous releases. + +This will also reduce the module cache usage by 320 MB (this will only affect users of lego as a library or who build lego themselves). + +### Added + +- **[dnsprovider]** Add DNS provider for ZoneEdit +- **[cli]** Add an option to define dynamically the renew date +- **[lib,cli]** Add an option to disable common name in CSR + +### Changed + +- **[dnsprovider]** vinyldns: add an option to add quotes around the TXT record value +- **[dnsprovider]** ionos: increase default propagation timeout + +### Fixed + +- **[cli]** fix: enforce domain into renewal command + +## v4.24.0 + +- Release date: 2025-07-07 +- Tag: [v4.24.0](https://github.com/go-acme/lego/releases/tag/v4.24.0) + +### Added + +- **[dnsprovider]** Add DNS provider for Azion +- **[dnsprovider]** Add DNS provider for DynDnsFree.de +- **[dnsprovider]** Add DNS provider for ConoHa v3 +- **[dnsprovider]** Add DNS provider for RU Center +- **[dnsprovider]** gcloud: add service account impersonation + +### Changed + +- **[dnsprovider]** pdns: improve error messages +- **[dnsprovider]** cloudflare: add quotation marks to TXT record +- **[dnsprovider]** googledomains: provider deprecation +- **[dnsprovider]** mijnhost: improve record filter + +### Fixed + +- **[dnsprovider]** exoscale: fix find record +- **[dnsprovider]** nicmanager: fix mode env var name and value +- **[lib,cli]** Check order identifiers difference between client and server + +## v4.23.1 + +- Release date: 2025-04-16 +- Tag: [v4.23.1](https://github.com/go-acme/lego/releases/tag/v4.23.1) + +Due to an error related to Snapcraft, some artifacts of the v4.23.0 release have not been published. + +This release contains the same things as v4.23.0. + +## v4.23.0 + +- Release date: 2025-04-16 +- Tag: [v4.23.0](https://github.com/go-acme/lego/releases/tag/v4.23.0) + +### Added + +- **[dnsprovider]** Add DNS provider for Active24 +- **[dnsprovider]** Add DNS provider for BookMyName +- **[dnsprovider]** Add DNS provider for Axelname +- **[dnsprovider]** Add DNS provider for Baidu Cloud +- **[dnsprovider]** Add DNS provider for Metaregistrar +- **[dnsprovider]** Add DNS provider for F5 XC +- **[dnsprovider]** Add INFOBLOX_CA_CERTIFICATE option +- **[dnsprovider]** route53: adds option to use private zone +- **[dnsprovider]** edgedns: add account switch key option +- **[dnsprovider]** infoblox: update API client to v2 +- **[lib,cli]** Add delay option for TLSALPN challenge + +### Changed + +- **[dnsprovider]** designate: speed up API requests by using filters +- **[dnsprovider]** cloudflare: make base URL configurable +- **[dnsprovider]** websupport: migrate to API v2 +- **[dnsprovider]** dnssimple: use GetZone + +### Fixed + +- **[ari]** Fix retry on `alreadyReplaced` error +- **[cli,log]** Fix malformed log messages +- **[cli]** Kill hook when the command is stuck +- **[dnsprovider]** pdns: fix TXT record cleanup for wildcard domains +- **[dnsprovider]** allinkl: remove `ReturnInfo` + +## v4.22.2 + +- Release date: 2025-02-17 +- Tag: [v4.22.2](https://github.com/go-acme/lego/releases/tag/v4.22.2) + +### Fixed + +- **[dnsprovider]** acme-dns: use new registred account + +## v4.22.1 + +- Release date: 2025-02-17 +- Tag: [v4.22.1](https://github.com/go-acme/lego/releases/tag/v4.22.1) + +### Fixed + +- **[dnsprovider]** acme-dns: continue the process when the CNAME is handled by the storage + +### Added + +## v4.22.0 + +- Release date: 2025-02-17 +- Tag: [v4.22.0](https://github.com/go-acme/lego/releases/tag/v4.22.0) + +### Added + +- **[cli]** Add `--private-key` flag to set the private key. +- **[cli]** Add `LEGO_DEBUG_ACME_HTTP_CLIENT` environment variable to debug the calls to the ACME server. +- **[cli]** Add `LEGO_EMAIL` environment variable for specifying email. +- **[cli]** Add `--hook-timeout` flag to run and renew commands. +- **[dnsprovider]** Add DNS provider for myaddr.{tools,dev,io} +- **[dnsprovider]** Add DNS provider for Spaceship +- **[dnsprovider]** acme-dns: add HTTP storage +- **[lib,cli,httpprovider]** Add `--http.delay` option for HTTP challenge. +- **[lib,cli,profiles]** Add support for Profiles Extension. +- **[lib]** Add an option to set CSR email addresses + +### Changed + +- **[lib]** rewrite status management +- **[dnsprovider]** docs: improve units and default values + +### Removed + +- **[dnsprovider]** netcup: remove TTL option + +### Fixed + +- **[cli,log]** remove extra debug logs + +## v4.21.0 + +- Release date: 2024-12-20 +- Tag: [v4.21.0](https://github.com/go-acme/lego/releases/tag/v4.21.0) + +### Added + +- **[dnsprovider]** Add DNS provider for Rainyun/雨云 +- **[dnsprovider]** Add DNS provider for West.cn/西部数码 +- **[dnsprovider]** Add DNS provider for ManageEngine CloudDNS +- **[cli]** feat: add --force-cert-domains flag to renew + +### Fixed + +- **[cli]** create client only when needed +- **[cli]** clone the transport with tls-skip-verify +- **[cli]** use retryable client for ACME server calls +- **[dnsprovider]** bunny: fix zone detection +- **[dnsprovider]** inwx: delete only the TXT record related to the DNS challenge +- **[dnsprovider]** infomaniak: increase default propagation timeout +- **[dnsprovider]** dnsmadeeasy: use default transport +- **[dnsprovider]** netcup: increase default propagation values +- **[dnsprovider]** otc: use default transport + +## v4.20.4 + +- Release date: 2024-11-21 +- Tag: [v4.20.4](https://github.com/go-acme/lego/releases/tag/v4.20.4) + +Publish the Snap to the Snapcraft stable channel. + +## v4.20.3 + +- Release date: 2024-11-21 +- Tag: [v4.20.3](https://github.com/go-acme/lego/releases/tag/v4.20.3) + +### Fixed + +- **[dnsprovider]** technitium: fix status code handling +- **[dnsprovider]** directadmin: fix timeout configuration +- **[httpprovider]** fix: HTTP server IPv6 matching + +## v4.20.2 + +- Release date: 2024-11-11 +- Tag: [v4.20.2](https://github.com/go-acme/lego/releases/tag/v4.20.2) ### Added @@ -28,20 +429,41 @@ - **[dnsprovider]** volcengine: set API information within the default configuration - **[log]** Parse printf verbs in log line output -## [v4.19.2] - 2024-10-06 +## v4.20.1 + +- Release date: 2024-11-11 + +Cancelled due to CI failure. + +## v4.20.0 + +- Release date: 2024-11-11 + +Cancelled due to CI failure. + +## v4.19.2 + +- Release date: 2024-10-06 +- Tag: [v4.19.2](https://github.com/go-acme/lego/releases/tag/v4.19.2) ### Fixed - **[lib]** go1.22 compatibility -## [v4.19.1] - 2024-10-06 +## v4.19.1 + +- Release date: 2024-10-06 +- Tag: [v4.19.1](https://github.com/go-acme/lego/releases/tag/v4.19.1) ### Fixed - **[dnsprovider]** selectelv2: use baseURL from configuration - **[dnsprovider]** epik: add User-Agent -## [v4.19.0] - 2024-10-03 +## v4.19.0 + +- Release date: 2024-10-03 +- Tag: [v4.19.0](https://github.com/go-acme/lego/releases/tag/v4.19.0) ### Added @@ -63,7 +485,10 @@ - **[dnsprovider]** namesilo: restrict CleanUp - **[dnsprovider]** godaddy: fix cleanup -## [v4.18.0] - 2024-08-30 +## v4.18.0 + +- Release date: 2024-08-30 +- Tag: [v4.18.0](https://github.com/go-acme/lego/releases/tag/v4.18.0) ### Added @@ -85,13 +510,19 @@ - **[ari]** fix: avoid Int63n panic in ShouldRenewAt() -## [v4.17.4] - 2024-06-12 +## v4.17.4 + +- Release date: 2024-06-12 +- Tag: [v4.17.4](https://github.com/go-acme/lego/releases/tag/v4.17.4) ### Fixed - **[dnsprovider]** Update dependencies -## [v4.17.3] - 2024-05-28 +## v4.17.3 + +- Release date: 2024-05-28 +- Tag: [v4.17.3](https://github.com/go-acme/lego/releases/tag/v4.17.3) ### Added @@ -119,13 +550,17 @@ - **[dnsprovider]** pdns: reconstruct zone URLs to enable non-root folder API endpoints - **[dnsprovider]** alidns: fix link to API documentation -## [v4.17.2] - 2024-05-28 +## v4.17.2 + +- Release date: 2024-05-28 Canceled due to a release failure related to Snapcraft. The Snapcraft release are disabled for now. -## [v4.17.1] - 2024-05-28 +## v4.17.1 + +- Release date: 2024-05-28 Canceled due to a release failure related to oci-go-sdk. @@ -134,17 +569,25 @@ The module `github.com/oracle/oci-go-sdk/v65` uses `github.com/gofrs/flock` but Due to that we will remove the Solaris build. -## [v4.17.0] - 2024-05-28 +## v4.17.0 + +- Release date: 2024-05-28 Canceled due to a release failure related to Snapcraft. -## [v4.16.1] - 2024-03-10 +## v4.16.1 + +- Release date: 2024-03-10 +- Tag: [v4.16.1](https://github.com/go-acme/lego/releases/tag/v4.16.1) ### Fixed - **[cli,ari]** fix: don't generate ARI cert ID if ARI is not enable -## [v4.16.0] - 2024-03-09 +## v4.16.0 + +- Release date: 2024-03-09 +- Tag: [v4.16.0](https://github.com/go-acme/lego/releases/tag/v4.16.0) ### Added @@ -165,7 +608,10 @@ Canceled due to a release failure related to Snapcraft. - **[dnsprovider]** easydns: fix zone detection - **[dnsprovider]** ns1: fix record creation -## [v4.15.0] - 2024-01-28 +## v4.15.0 + +- Release date: 2024-01-28 +- Tag: [v4.15.0](https://github.com/go-acme/lego/releases/tag/v4.15.0) ### Added @@ -203,7 +649,10 @@ Canceled due to a release failure related to Snapcraft. - **[dnsprovider]** nifcloud: fix API requests - **[dnsprovider]** otc: sequential challenge -## [v4.14.1] - 2023-09-20 +## v4.14.1 + +- Release date: 2023-09-20 +- Tag: [v4.14.1](https://github.com/go-acme/lego/releases/tag/v4.14.1) ### Fixed @@ -211,11 +660,16 @@ Canceled due to a release failure related to Snapcraft. - **[dnsprovider]** bunny: use NRDCG fork - **[dnsprovider]** ovh: update client to v1.4.2 -## [v4.14.1] - 2023-09-19 +## v4.14.1 + +- Release date: 2023-09-19 Cancelled due to CI failure. -## [v4.14.0] - 2023-08-20 +## v4.14.0 + +- Release date: 2023-08-20 +- Tag: [v4.14.0](https://github.com/go-acme/lego/releases/tag/v4.14.0) ### Added @@ -234,20 +688,29 @@ Cancelled due to CI failure. - **[dnsprovider]** pdns: fix notify - **[dnsprovider]** route53: avoid unexpected records deletion -## [v4.13.3] - 2023-07-25 +## v4.13.3 + +- Release date: 2023-07-25 +- Tag: [v4.13.3](https://github.com/go-acme/lego/releases/tag/v4.13.3) ### Fixed - **[dnsprovider]** azuredns: fix configuration from env vars - **[dnsprovider]** gcore: change API domain -## [v4.13.2] - 2023-07-21 +## v4.13.2 + +- Release date: 2023-07-21 +- Tag: [v4.13.2](https://github.com/go-acme/lego/releases/tag/v4.13.2) ### Fixed - **[dnsprovider]** servercow: fix regression -## [v4.13.1] - 2023-07-20 +## v4.13.1 + +- Release date: 2023-07-20 +- Tag: [v4.13.1](https://github.com/go-acme/lego/releases/tag/v4.13.1) ### Added @@ -268,24 +731,35 @@ Cancelled due to CI failure. - **[cli]** fix: list command - **[lib]** fix: ARI explanationURL -## [v4.13.0] - 2023-07-20 +## v4.13.0 + +- Release date: 2023-07-20 Cancelled due to a CI issue (no space left on device). -## [v4.12.2] - 2023-06-19 +## v4.12.2 + +- Release date: 2023-06-19 +- Tag: [v4.12.2](https://github.com/go-acme/lego/releases/tag/v4.12.2) ### Fixed - **[dnsprovider]** dnsmadeeasy: fix DeleteRecord - **[lib]** fix: read status code from response -## [v4.12.1] - 2023-06-06 +## v4.12.1 + +- Release date: 2023-06-06 +- Tag: [v4.12.1](https://github.com/go-acme/lego/releases/tag/v4.12.1) ### Fixed - **[dnsprovider]** pdns: fix record value -## [v4.12.0] - 2023-05-28 +## v4.12.0 + +- Release date: 2023-05-28 +- Tag: [v4.12.0](https://github.com/go-acme/lego/releases/tag/v4.12.0) ### Added @@ -303,7 +777,10 @@ Cancelled due to a CI issue (no space left on device). - **[dnsprovider]** autodns: fixes wrong zone in api call if CNAME is used - **[cli]** fix: archive only domain-related files on revoke -## [v4.11.0] - 2023-05-02 +## v4.11.0 + +- Release date: 2023-05-02 +- Tag: [v4.11.0](https://github.com/go-acme/lego/releases/tag/v4.11.0) ### Added @@ -325,18 +802,27 @@ Cancelled due to a CI issue (no space left on device). - **[dnsprovider]** rimuhosting: fix API base URL -## [v4.10.2] - 2023-02-26 +## v4.10.2 + +- Release date: 2023-02-26 +- Tag: [v4.10.2](https://github.com/go-acme/lego/releases/tag/v4.10.2) Fix Docker image builds. -## [v4.10.1] - 2023-02-25 +## v4.10.1 + +- Release date: 2023-02-25 +- Tag: [v4.10.1](https://github.com/go-acme/lego/releases/tag/v4.10.1) ### Fixed - **[dnsprovider,cname]** acmedns: fix CNAME support - **[dnsprovider]** dynu: fix subdomain support -## [v4.10.0] - 2023-02-10 +## v4.10.0 + +- Release date: 2023-02-10 +- Tag: [v4.10.0](https://github.com/go-acme/lego/releases/tag/v4.10.0) ### Added @@ -362,22 +848,28 @@ Fix Docker image builds. - **[dnsprovider]** pdns: fix usage of notify only when zone kind is Master or Slave - **[dnsprovider]** return an error when extracting record name -## [v4.9.1] - 2022-11-25 +## v4.9.1 + +- Release date: 2022-11-25 +- Tag: [v4.9.1](https://github.com/go-acme/lego/releases/tag/v4.9.1) ### Changed -- + - **[lib,cname]** cname: add log about CNAME entries - **[dnsprovider]** regru: improve error handling ### Fixed -- + - **[dnsprovider,cname]** fix CNAME support for multiple DNS providers - **[dnsprovider,cname]** duckdns: fix CNAME support - **[dnsprovider,cname]** oraclecloud: use fqdn to resolve zone - **[dnsprovider]** hurricane: fix CNAME support - **[lib,cname]** cname: stop trying to traverse cname if none have been found -## [v4.9.0] - 2022-10-03 +## v4.9.0 + +- Release date: 2022-10-03 +- Tag: [v4.9.0](https://github.com/go-acme/lego/releases/tag/v4.9.0) ### Added @@ -407,7 +899,10 @@ Fix Docker image builds. - **[dnsprovider]** njalla: fix record id unmarshal error - **[dnsprovider]** tencentcloud: fix subdomain error -## [v4.8.0] - 2022-06-30 +## v4.8.0 + +- Release date: 2022-06-30 +- Tag: [v4.8.0](https://github.com/go-acme/lego/releases/tag/v4.8.0) ### Added @@ -423,9 +918,12 @@ Fix Docker image builds. - **[dnsprovider]** hetzner: set min TTL to 60s - **[docs]** refactoring and cleanup -## [v4.7.0] - 2022-05-27 +## v4.7.0 -### Added: +- Release date: 2022-05-27 +- Tag: [v4.7.0](https://github.com/go-acme/lego/releases/tag/v4.7.0) + +### Added - **[dnsprovider]** Add DNS provider for iwantmyname - **[dnsprovider]** Add DNS Provider for IIJ DNS Platform Service @@ -434,18 +932,21 @@ Fix Docker image builds. - **[dnsprovider]** dnsimple: add debug option - **[cli]** feat: add `LEGO_CERT_PEM_PATH` and `LEGO_CERT_PFX_PATH` -### Changed: +### Changed - **[dnsprovider]** gcore: change dns api url - **[dnsprovider]** bluecat: rewrite provider implementation -### Fixed: +### Fixed - **[dnsprovider]** rfc2136: fix TSIG secret - **[dnsprovider]** tencentcloud: fix InvalidParameter.DomainInvalid error when using DNS challenges - **[lib]** fix: panic in certcrypto.ParsePEMPrivateKey -## [v4.6.0] - 2022-01-18 +## v4.6.0 + +- Release date: 2022-01-18 +- Tag: [v4.6.0](https://github.com/go-acme/lego/releases/tag/v4.6.0) ### Added @@ -467,15 +968,21 @@ Fix Docker image builds. - **[dnsprovider]** mythicbeasts: fix token expiration - **[dnsprovider]** rackspace: change zone ID to string -## [v4.5.3] - 2021-09-06 +## v4.5.3 -### Fixed: +- Release date: 2021-09-06 +- Tag: [v4.5.3](https://github.com/go-acme/lego/releases/tag/v4.5.3) + +### Fixed - **[lib,cli]** fix: missing preferred chain param for renew request -## [v4.5.2] - 2021-09-01 +## v4.5.2 -### Added: +- Release date: 2021-09-01 +- Tag: [v4.5.2](https://github.com/go-acme/lego/releases/tag/v4.5.2) + +### Added - **[dnsprovider]** Add DNS provider for all-inkl - **[dnsprovider]** Add DNS provider for Epik @@ -486,7 +993,7 @@ Fix Docker image builds. - **[dnsprovider]** Add DNS provider for Internet.bs - **[dnsprovider]** Add DNS provider for nicmanager -### Changed: +### Changed - **[dnsprovider]** alidns: support ECS instance RAM role - **[dnsprovider]** alidns: support sts token credential @@ -494,7 +1001,7 @@ Fix Docker image builds. - **[dnsprovider]** ovh: follow cname - **[lib,cli]** Add AlwaysDeactivateAuthorizations flag to ObtainRequest -### Fixed: +### Fixed - **[dnsprovider]** infomaniak: fix subzone support - **[dnsprovider]** edgedns: fix Present and CleanUp logic @@ -503,17 +1010,24 @@ Fix Docker image builds. - **[lib]** lib: use permanent error instead of context cancellation - **[dnsprovider]** desec: bump to v0.6.0 -## [v4.5.1] - 2021-09-01 +## v4.5.1 + +- Release date: 2021-10-01 Cancelled due to a CI issue, replaced by v4.5.2. -## [v4.5.0] - 2021-09-30 +## v4.5.0 + +- Release date: 2021-09-30 Cancelled due to a CI issue, replaced by v4.5.2. -## [v4.4.0] - 2021-06-08 +## v4.4.0 -### Added: +- Release date: 2021-06-08 +- Tag: [v4.4.0](https://github.com/go-acme/lego/releases/tag/v4.4.0) + +### Added - **[dnsprovider]** Add DNS provider for Infoblox - **[dnsprovider]** Add DNS provider for Porkbun @@ -522,7 +1036,7 @@ Cancelled due to a CI issue, replaced by v4.5.2. - **[dnsprovider]** Add DNS provider for VinylDNS - **[dnsprovider]** Add DNS provider for wedos -### Changed: +### Changed - **[cli]** log: Use stderr instead of stdout. - **[dnsprovider]** hostingde: autodetection of the zone name. @@ -530,7 +1044,7 @@ Cancelled due to a CI issue, replaced by v4.5.2. - **[dnsprovider]** powerdns: several improvements - **[lib]** lib: improve wait.For returns. -### Fixed: +### Fixed - **[dnsprovider]** hurricane: add API rate limiter. - **[dnsprovider]** hurricane: only treat first word of response body as response code @@ -539,15 +1053,21 @@ Cancelled due to a CI issue, replaced by v4.5.2. - **[dnsprovider]** nifcloud: Get zone info from dns01.FindZoneByFqdn - **[cli,lib]** csr: Support the type `NEW CERTIFICATE REQUEST` -## [v4.3.1] - 2021-03-12 +## v4.3.1 -### Fixed: +- Release date: 2021-03-12 +- Tag: [v4.3.1](https://github.com/go-acme/lego/releases/tag/v4.3.1) + +### Fixed - **[dnsprovider]** exoscale: fix dependency version. -## [v4.3.0] - 2021-03-10 +## v4.3.0 -### Added: +- Release date: 2021-03-10 +- Tag: [v4.3.0](https://github.com/go-acme/lego/releases/tag/v4.3.0) + +### Added - **[dnsprovider]** Add DNS provider for Njalla - **[dnsprovider]** Add DNS provider for Domeneshop @@ -555,13 +1075,13 @@ Cancelled due to a CI issue, replaced by v4.5.2. - **[dnsprovider]** designate: support for Openstack Application Credentials - **[dnsprovider]** edgedns: support for .edgerc file -### Changed: +### Changed - **[dnsprovider]** infomaniak: Make error message more meaningful - **[dnsprovider]** cloudns: Improve reliability - **[dnsprovider]** rfc2163: Removed support for MD5 algorithm. The default algorithm is now SHA1. -### Fixed: +### Fixed - **[dnsprovider]** desec: fix error with default TTL - **[dnsprovider]** mythicbeasts: implement `ProviderTimeout` @@ -569,119 +1089,146 @@ Cancelled due to a CI issue, replaced by v4.5.2. - **[lib]** Increase HTTP client timeouts - **[lib]** preferred chain only match root name -## [v4.2.0] - 2021-01-24 +## v4.2.0 -### Added: +- Release date: 2021-01-24 +- Tag: [v4.2.0](https://github.com/go-acme/lego/releases/tag/v4.2.0) + +### Added - **[dnsprovider]** Add DNS provider for Loopia - **[dnsprovider]** Add DNS provider for Ionos. -### Changed: +### Changed - **[dnsprovider]** acme-dns: update cpu/goacmedns to v0.1.1. - **[dnsprovider]** inwx: Increase propagation timeout to 360s to improve robustness - **[dnsprovider]** vultr: Update to govultr v2 API - **[dnsprovider]** pdns: get exact zone instead of all zones -### Fixed: +### Fixed - **[dnsprovider]** vult, dnspod: fix default HTTP timeout. - **[dnsprovider]** pdns: URL request creation. - **[lib]** errors: Fix instance not being printed -## [v4.1.3] - 2020-11-25 +## v4.1.3 -### Fixed: +- Release date: 2020-11-25 +- Tag: [v4.1.3](https://github.com/go-acme/lego/releases/tag/v4.1.3) + +### Fixed - **[dnsprovider]** azure: fix error handling. -## [v4.1.2] - 2020-11-21 +## v4.1.2 -### Fixed: +- Release date: 2020-11-21 +- Tag: [v4.1.2](https://github.com/go-acme/lego/releases/tag/v4.1.2) + +### Fixed - **[lib]** fix: preferred chain support. -## [v4.1.1] - 2020-11-19 +## v4.1.1 -### Fixed: +- Release date: 2020-11-19 +- Tag: [v4.1.1](https://github.com/go-acme/lego/releases/tag/v4.1.1) + +### Fixed - **[dnsprovider]** otc: select correct zone if multiple returned - **[dnsprovider]** azure: fix target must be a non-nil pointer -## [v4.1.0] - 2020-11-06 +## v4.1.0 -### Added: +- Release date: 2020-11-06 +- Tag: [v4.1.0](https://github.com/go-acme/lego/releases/tag/v4.1.0) + +### Added - **[dnsprovider]** Add DNS provider for Infomaniak - **[dnsprovider]** joker: add support for SVC API - **[dnsprovider]** gcloud: add an option to allow the use of private zones -### Changed: +### Changed - **[dnsprovider]** rfc2136: ensure TSIG algorithm is fully qualified - **[dnsprovider]** designate: Deprecate OS_TENANT_NAME as required field -### Fixed: +### Fixed - **[lib]** acme/api: use postAsGet instead of post for AccountService.Get - **[lib]** fix: use http.Header.Set method instead of Add. -## [v4.0.1] - 2020-09-03 +## v4.0.1 -### Fixed: +- Release date: 2020-09-03 +- Tag: [v4.0.1](https://github.com/go-acme/lego/releases/tag/v4.0.1) + +### Fixed - **[dnsprovider]** exoscale: change dependency version. -## [v4.0.0] - 2020-09-02 +## v4.0.0 -### Added: +- Release date: 2020-09-02 +- Tag: [v4.0.0](https://github.com/go-acme/lego/releases/tag/v4.0.0) + +### Added - **[cli], [lib]** Support "alternate" certificate links for selecting different signing Chains -### Changed: +### Changed - **[cli]** Replaces `ec384` by `ec256` as default key-type - **[lib]** Changes `ObtainForCSR` method signature -### Removed: +### Removed - **[dnsprovider]** Replaces FastDNS by EdgeDNS - **[dnsprovider]** Removes old Linode provider - **[lib]** Removes `AddPreCheck` function -## [v3.9.0] - 2020-09-01 +## v3.9.0 -### Added: +- Release date: 2020-09-01 +- Tag: [v3.9.0](https://github.com/go-acme/lego/releases/tag/v3.9.0) + +### Added - **[dnsprovider]** Add Akamai Edgedns. Deprecate FastDNS - **[dnsprovider]** Add DNS provider for HyperOne -### Changed: +### Changed - **[dnsprovider]** designate: add support for Openstack clouds.yaml - **[dnsprovider]** azure: allow selecting environments - **[dnsprovider]** desec: applies API rate limits. -### Fixed: +### Fixed - **[dnsprovider]** namesilo: fix cleanup. -## [v3.8.0] - 2020-07-02 +## v3.8.0 -### Added: +- Release date: 2020-07-02 +- Tag: [v3.8.0](https://github.com/go-acme/lego/releases/tag/v3.8.0) + +### Added - **[cli]** cli: add hook on the run command. - **[dnsprovider]** inwx: Two-Factor-Authentication - **[dnsprovider]** Add DNS provider for ArvanCloud -### Changed: +### Changed - **[dnsprovider]** vultr: bumping govultr version - **[dnsprovider]** desec: improve error logs. - **[lib]** Ensures the return of a location during account updates - **[dnsprovider]** route53: Document all AWS credential environment variables -### Fixed: +### Fixed - **[dnsprovider]** stackpath: fix subdomain support. - **[dnsprovider]** arvandcloud: fix record name. @@ -690,9 +1237,12 @@ Cancelled due to a CI issue, replaced by v4.5.2. - **[dnsprovider]** hetzner: fix record name. - **[lib]** Registrar.ResolveAccountByKey: Fix malformed request -## [v3.7.0] - 2020-05-11 +## v3.7.0 -### Added: +- Release date: 2020-05-11 +- Tag: [v3.7.0](https://github.com/go-acme/lego/releases/tag/v3.7.0) + +### Added - **[dnsprovider]** Add DNS provider for Netlify. - **[dnsprovider]** Add DNS provider for deSEC.io @@ -701,28 +1251,31 @@ Cancelled due to a CI issue, replaced by v4.5.2. - **[dnsprovider]** Add DNS provider for Mythic beasts DNSv2 - **[dnsprovider]** Add DNS provider for Yandex. -### Changed: +### Changed - **[dnsprovider]** Upgrade DNSimple client to 0.60.0 - **[dnsprovider]** update aws sdk -### Fixed: +### Fixed - **[dnsprovider]** autodns: removes TXT records during CleanUp. - **[dnsprovider]** Fix exoscale HTTP timeout - **[cli]** fix: renew path information. - **[cli]** Fix account storage location warning message -## [v3.6.0] - 2020-04-24 +## v3.6.0 -### Added: +- Release date: 2020-04-24 +- Tag: [v3.6.0](https://github.com/go-acme/lego/releases/tag/v3.6.0) + +### Added - **[dnsprovider]** Add DNS provider for CloudDNS. - **[dnsprovider]** alicloud: add support for domain with punycode - **[dnsprovider]** cloudns: Add subuser support - **[cli]** Information about renewed certificates are now passed to the renew hook -### Changed: +### Changed - **[dnsprovider]** acmedns: Update cpu/goacmedns v0.0.1 -> v0.0.2 - **[dnsprovider]** alicloud: update sdk dependency version to v1.61.112 @@ -732,14 +1285,17 @@ Cancelled due to a CI issue, replaced by v4.5.2. - **[dnsprovider]** namedotcom: get the actual registered domain, so we can remove just that from the hostname to be created - **[dnsprovider]** transip: updated the client to v6 -### Fixed: +### Fixed - **[dnsprovider]** ns1: fix missing domain in log - **[dnsprovider]** rimuhosting: use HTTP client from config. -## [v3.5.0] - 2020-03-15 +## v3.5.0 -### Added: +- Release date: 2020-03-15 +- Tag: [v3.5.0](https://github.com/go-acme/lego/releases/tag/v3.5.0) + +### Added - **[dnsprovider]** Add DNS provider for Dynu. - **[dnsprovider]** Add DNS provider for reg.ru @@ -749,27 +1305,30 @@ Cancelled due to a CI issue, replaced by v4.5.2. - **[cli]** Multi-arch Docker image. - **[cli]** Adds `--name` flag to list command. -### Changed: +### Changed - **[lib]** lib: Improve cleanup log messages. - **[lib]** Wrap errors. -### Fixed: +### Fixed - **[dnsprovider]** azure: pass AZURE_CLIENT_SECRET_FILE to autorest.Authorizer - **[dnsprovider]** gcloud: fixes issues when used with GKE Workload Identity - **[dnsprovider]** oraclecloud: fix subdomain support -## [v3.4.0] - 2020-02-25 +## v3.4.0 -### Added: +- Release date: 2020-02-25 +- Tag: [v3.4.0](https://github.com/go-acme/lego/releases/tag/v3.4.0) + +### Added - **[dnsprovider]** Add DNS provider for Constellix - **[dnsprovider]** Add DNS provider for Servercow. - **[dnsprovider]** Add DNS provider for Scaleway - **[cli]** Add "LEGO_PATH" environment variable -### Changed: +### Changed - **[dnsprovider]** route53: allow custom client to be provided - **[dnsprovider]** namecheap: allow external domains @@ -777,7 +1336,7 @@ Cancelled due to a CI issue, replaced by v4.5.2. - **[dnsprovider]** ovh: Improve provider documentation - **[dnsprovider]** route53: Improve provider documentation -### Fixed: +### Fixed - **[dnsprovider]** zoneee: fix subdomains. - **[dnsprovider]** designate: Don't clean up managed records like SOA and NS @@ -785,147 +1344,213 @@ Cancelled due to a CI issue, replaced by v4.5.2. - **[lib]** crypto: Treat CommonName as optional - **[lib]** chore: update cenkalti/backoff to v4. -## [v3.3.0] - 2020-01-08 +## v3.3.0 + +- Release date: 2020-01-08 +- Tag: [v3.3.0](https://github.com/go-acme/lego/releases/tag/v3.3.0) + +### Added -### Added: - **[dnsprovider]** Add DNS provider for Checkdomain - **[lib]** Add support to update account -### Changed: +### Changed + - **[dnsprovider]** gcloud: Auto-detection of the project ID. - **[lib]** Successfully parse private key PEM blocks -### Fixed: +### Fixed + - **[dnsprovider]** Update dnspod, because of API breaking changes. -## [v3.2.0] - 2019-11-10 +## v3.2.0 + +- Release date: 2019-11-10 +- Tag: [v3.2.0](https://github.com/go-acme/lego/releases/tag/v3.2.0) + +### Added -### Added: - **[dnsprovider]** Add support for autodns -### Changed: +### Changed + - **[dnsprovider]** httpreq: Allow use environment vars from a `_FILE` file - **[lib]** Don't deactivate valid authorizations - **[lib]** Expose more SOA fields found by dns01.FindZoneByFqdn -### Fixed: +### Fixed + - **[dnsprovider]** use token as unique ID. -## [v3.1.0] - 2019-10-07 +## v3.1.0 + +- Release date: 2019-10-07 +- Tag: [v3.1.0](https://github.com/go-acme/lego/releases/tag/v3.1.0) + +### Added -### Added: - **[dnsprovider]** Add DNS provider for Liquid Web - **[dnsprovider]** cloudflare: add support for API tokens - **[cli]** feat: ease operation behind proxy servers -### Changed: +### Changed + - **[dnsprovider]** cloudflare: update client - **[dnsprovider]** linodev4: propagation timeout configuration. -### Fixed: +### Fixed + - **[dnsprovider]** ovh: fix int overflow. - **[dnsprovider]** bindman: fix client version. -## [v3.0.2] - 2019-08-15 +## v3.0.2 + +- Release date: 2019-08-15 +- Tag: [v3.0.2](https://github.com/go-acme/lego/releases/tag/v3.0.2) + +### Fixed -### Fixed: - Invalid pseudo version (related to Cloudflare client). -## [v3.0.1] - 2019-08-14 +## v3.0.1 + +- Release date: 2019-08-14 +- Tag: [v3.0.1](https://github.com/go-acme/lego/releases/tag/v3.0.1) There was a problem when creating the tag v3.0.1, this tag has been invalidated. -## [v3.0.0] - 2019-08-05 +## v3.0.0 + +- Release date: 2019-08-05 +- Tag: [v3.0.0](https://github.com/go-acme/lego/releases/tag/v3.0.0) + +### Changed -### Changed: - migrate to go module (new import github.com/go-acme/lego/v3/) - update DNS clients -## [v2.7.2] - 2019-07-30 +## v2.7.2 + +- Release date: 2019-07-30 +- Tag: [v2.7.2](https://github.com/go-acme/lego/releases/tag/v2.7.2) + +### Fixed -### Fixed: - **[dnsprovider]** vultr: quote TXT record -## [v2.7.1] - 2019-07-22 +## v2.7.1 + +- Release date: 2019-07-22 +- Tag: [v2.7.1](https://github.com/go-acme/lego/releases/tag/v2.7.1) + +### Fixed -### Fixed: - **[dnsprovider]** vultr: invalid record type. -## [v2.7.0] - 2019-07-17 +## v2.7.0 + +- Release date: 2019-07-17 +- Tag: [v2.7.0](https://github.com/go-acme/lego/releases/tag/v2.7.0) + +### Added -### Added: - **[dnsprovider]** Add DNS provider for namesilo - **[dnsprovider]** Add DNS provider for versio.nl -### Changed: +### Changed + - **[dnsprovider]** Update DNS providers libs. - **[dnsprovider]** joker: support username and password. - **[dnsprovider]** Vultr: Switch to official client -### Fixed: +### Fixed + - **[dnsprovider]** otc: Prevent sending empty body. -## [v2.6.0] - 2019-05-27 +## v2.6.0 + +- Release date: 2019-05-27 +- Tag: [v2.6.0](https://github.com/go-acme/lego/releases/tag/v2.6.0) + +### Added -### Added: - **[dnsprovider]** Add support for Joker.com DMAPI - **[dnsprovider]** Add support for Bindman DNS provider - **[dnsprovider]** Add support for EasyDNS - **[lib]** Get an existing certificate by URL -### Changed: +### Changed + - **[dnsprovider]** digitalocean: LEGO_EXPERIMENTAL_CNAME_SUPPORT support - **[dnsprovider]** gcloud: Use fqdn to get zone Present/CleanUp - **[dnsprovider]** exec: serial behavior - **[dnsprovider]** manual: serial behavior. - **[dnsprovider]** Strip newlines when reading environment variables from `_FILE` suffixed files. -### Fixed: +### Fixed + - **[cli]** fix: cli disable-cp option. - **[dnsprovider]** gcloud: fix zone visibility. -## [v2.5.0] - 2019-04-17 +## v2.5.0 + +- Release date: 2019-04-17 +- Tag: [v2.5.0](https://github.com/go-acme/lego/releases/tag/v2.5.0) + +### Added -### Added: - **[cli]** Adds renew hook - **[dnsprovider]** Adds 'Since' to DNS providers documentation -### Changed: +### Changed + - **[dnsprovider]** gcloud: use public DNS zones - **[dnsprovider]** route53: enhance documentation. -### Fixed: +### Fixed + - **[dnsprovider]** cloudns: fix TTL and status validation - **[dnsprovider]** sakuracloud: supports concurrent update - **[dnsprovider]** Disable authz when solve fail. - Add tzdata to the Docker image. -## [v2.4.0] - 2019-03-25 +## v2.4.0 -- Migrate from xenolf/lego to go-acme/lego. +- Release date: 2019-03-25 +- Tag: [v2.4.0](https://github.com/go-acme/lego/releases/tag/v2.4.0) + +Migrate from xenolf/lego to go-acme/lego. + +### Added -### Added: - **[dnsprovider]** Add DNS Provider for Domain Offensive (do.de) - **[dnsprovider]** Adds information about '_FILE' suffix. -### Fixed: +### Fixed + - **[cli,dnsprovider]** Add 'manual' provider to the output of dnshelp - **[dnsprovider]** hostingde: Use provided ZoneName instead of domain - **[dnsprovider]** pdns: fix wildcard with SANs -## [v2.3.0] - 2019-03-11 +## v2.3.0 + +- Release date: 2019-03-11 +- Tag: [v2.3.0](https://github.com/go-acme/lego/releases/tag/v2.3.0) + +### Added -### Added: - **[dnsprovider]** Add DNS Provider for ClouDNS.net - **[dnsprovider]** Add DNS Provider for Oracle Cloud -### Changed: +### Changed + - **[cli]** Adds log when no renewal. - **[dnsprovider,lib]** Add a mechanism to wrap a PreCheckFunc - **[dnsprovider]** oraclecloud: better way to get private key. - **[dnsprovider]** exoscale: update library -### Fixed: +### Fixed + - **[dnsprovider]** OVH: Refresh zone after deleting challenge record - **[dnsprovider]** oraclecloud: ttl config and timeout - **[dnsprovider]** hostingde: fix client fails if customer has no access to dns-groups @@ -934,40 +1559,56 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated. - **[dnsprovider]** vscale: fix TXT records clean up - **[dnsprovider]** selectel: fix TXT records clean up -## [v2.2.0] - 2019-02-08 +## v2.2.0 + +- Release date: 2019-02-08 +- Tag: [v2.2.0](https://github.com/go-acme/lego/releases/tag/v2.2.0) + +### Added -### Added: - **[dnsprovider]** Add support for Openstack Designate as a DNS provider - **[dnsprovider]** gcloud: Option to specify gcloud service account json by env as string - **[experimental feature]** Resolve CNAME when creating dns-01 challenge. To enable: set `LEGO_EXPERIMENTAL_CNAME_SUPPORT` to `true`. -### Changed: +### Changed + - **[cli]** Applies Let’s Encrypt’s recommendation about renew. The option `--days` of the command `renew` has a new default value (`30`) - **[lib]** Uses a jittered exponential backoff -### Fixed: +### Fixed + - **[cli]** CLI and key type. - **[dnsprovider]** httpreq: Endpoint with path. - **[dnsprovider]** fastdns: Do not overwrite existing TXT records - Log wildcard domain correctly in validation -## [v2.1.0] - 2019-01-24 +## v2.1.0 + +- Release date: 2019-01-24 +- Tag: [v2.1.0](https://github.com/go-acme/lego/releases/tag/v2.1.0) + +### Added -### Added: - **[dnsprovider]** Add support for zone.ee as a DNS provider. -### Changed: +### Changed + - **[dnsprovider]** nifcloud: Change DNS base url. - **[dnsprovider]** gcloud: More detailed information about Google Cloud DNS. -### Fixed: +### Fixed + - **[lib]** fix: OCSP, set HTTP client. - **[dnsprovider]** alicloud: fix pagination. - **[dnsprovider]** namecheap: fix panic. -## [v2.0.0] - 2019-01-09 +## v2.0.0 + +- Release date: 2019-01-09 +- Tag: [v2.0.0](https://github.com/go-acme/lego/releases/tag/v2.0.0) + +### Added -### Added: - **[cli,lib]** Option to disable the complete propagation Requirement - **[lib,cli]** Support non-ascii domain name (punnycode) - **[cli,lib]** Add configurable timeout when obtaining certificates @@ -984,7 +1625,8 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated. - **[dnsprovider]** Add DNS Provider for inwx - **[dnsprovider]** alidns: add support to handle more than 20 domains -### Changed: +### Changed + - **[lib]** Check all challenges in a predictable order - **[lib]** Poll authz URL instead of challenge URL - **[lib]** Check all nameservers in a predictable order @@ -999,13 +1641,15 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated. - **[cli]** the option `--days` of the command `renew` has default value (`15`) - **[dnsprovider]** gcloud: Use GCE_PROJECT for project always, if specified -### Removed: +### Removed + - **[lib]** Remove `SetHTTP01Address` - **[lib]** Remove `SetTLSALPN01Address` - **[lib]** Remove `Exclude` - **[cli]** Remove `--exclude`, `-x` -### Fixed: +### Fixed + - **[lib]** Fixes revocation for subdomains and non-ascii domains - **[lib]** Disable pending authorizations - **[dnsprovider]** transip: concurrent access to the API. @@ -1013,17 +1657,23 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated. - **[dnsprovider]** Azure: Do not overwrite existing TXT records - **[dnsprovider]** fix: Cloudflare error. -## [v1.2.0] - 2018-11-04 +## v1.2.0 + +- Release date: 2018-11-04 +- Tag: [v1.2.0](https://github.com/go-acme/lego/releases/tag/v1.2.0) + +### Added -### Added: - **[dnsprovider]** Add DNS Provider for ConoHa DNS - **[dnsprovider]** Add DNS Provider for MyDNS.jp - **[dnsprovider]** Add DNS Provider for Selectel -### Fixed: +### Fixed + - **[dnsprovider]** netcup: make unmarshalling of api-responses more lenient. -### Changed: +### Changed + - **[dnsprovider]** aurora: change DNS client - **[dnsprovider]** azure: update auth to support instance metadata service - **[dnsprovider]** dnsmadeeasy: log response body on error @@ -1031,9 +1681,13 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated. - **[lib]** Do not send a JWS body when POSTing challenges. - **[lib]** Support POST-as-GET. -## [v1.1.0] - 2018-10-16 +## v1.1.0 + +- Release date: 2018-10-16 +- Tag: [v1.1.0](https://github.com/go-acme/lego/releases/tag/v1.1.0) + +### Added -### Added: - **[lib]** TLS-ALPN-01 Challenge - **[cli]** Add filename parameter - **[dnsprovider]** Allow to configure TTL, interval and timeout @@ -1051,7 +1705,8 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated. - **[dnsprovider]** exec: add EXEC_MODE=RAW support. - **[dnsprovider]** cloudflare: support for CF_API_KEY and CF_API_EMAIL -### Fixed: +### Fixed + - **[lib]** Don't trust identifiers order. - **[lib]** Fix missing issuer certificates from Let's Encrypt - **[dnsprovider]** duckdns: fix TXT record update url @@ -1061,20 +1716,29 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated. - **[dnsprovider]** ns1: use the authoritative zone and not the domain name - **[dnsprovider]** ovh: check error to avoid panic due to nil client -### Changed: +### Changed + - **[lib]** Submit all dns records up front, then validate serially -## [v1.0.0] - 2018-05-30 +## v1.0.0 + +- Release date: 2018-05-30 +- Tag: [v1.0.0](https://github.com/go-acme/lego/releases/tag/v1.0.0) + +### Changed -### Changed: - **[lib]** ACME v2 Support. - **[dnsprovider]** Renamed `/providers/dns/googlecloud` to `/providers/dns/gcloud`. - **[dnsprovider]** Modified Google Cloud provider `gcloud.NewDNSProviderServiceAccount` function to extract the project id directly from the service account file. - **[dnsprovider]** Made errors more verbose for the Cloudflare provider. -## [v0.5.0] - 2018-05-29 +## v0.5.0 + +- Release date: 2018-05-29 +- Tag: [v0.5.0](https://github.com/go-acme/lego/releases/tag/v0.5.0) + +### Added -### Added: - **[dnsprovider]** Add DNS challenge provider `exec` - **[dnsprovider]** Add DNS Provider for Akamai FastDNS - **[dnsprovider]** Add DNS Provider for Bluecat DNS @@ -1086,7 +1750,8 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated. - **[dnsprovider]** Add DNS Provider for Lightsail - **[dnsprovider]** Add DNS Provider for Name.com -### Fixed: +### Fixed + - **[dnsprovider]** Azure: Added missing environment variable in the comments - **[dnsprovider]** PowerDNS: Fix zone URL, add leading slash. - **[dnsprovider]** DNSimple: Fix api @@ -1095,7 +1760,8 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated. - **[lib]** Fix zone detection for cross-zone cnames. - **[lib]** Use proxies from environment when making outbound http connections. -### Changed: +### Changed + - **[lib]** Users of an effective top-level domain can use the DNS challenge. - **[dnsprovider]** Azure: Refactor to work with new Azure SDK version. - **[dnsprovider]** Cloudflare and Azure: Adding output of which envvars are missing. @@ -1103,20 +1769,29 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated. - **[dnsprovider]** Exoscale: update to latest egoscale version. - **[dnsprovider]** Route53: Use NewSessionWithOptions instead of deprecated New. -## [0.4.1] - 2017-09-26 +## 0.4.1 + +- Release date: 2017-09-26 +- Tag: [0.4.1](https://github.com/go-acme/lego/releases/tag/0.4.1) + +### Added -### Added: - lib: A new DNS provider for OTC. - lib: The `AWS_HOSTED_ZONE_ID` environment variable for the Route53 DNS provider to directly specify the zone. - lib: The `RFC2136_TIMEOUT` environment variable to make the timeout for the RFC2136 provider configurable. - lib: The `GCE_SERVICE_ACCOUNT_FILE` environment variable to specify a service account file for the Google Cloud DNS provider. -### Fixed: +### Fixed + - lib: Fixed an authentication issue with the latest Azure SDK. -## [0.4.0] - 2017-07-13 +## 0.4.0 + +- Release date: 2017-07-13 +- Tag: [0.4.0](https://github.com/go-acme/lego/releases/tag/0.4.0) + +### Added -### Added: - CLI: The `--http-timeout` switch. This allows for an override of the default client HTTP timeout. - lib: The `HTTPClient` field. This allows for an override of the default HTTP timeout for library HTTP requests. - CLI: The `--dns-timeout` switch. This allows for an override of the default DNS timeout for library DNS requests. @@ -1142,14 +1817,17 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated. - lib: A new DNS provider for Exoscale DNS. - lib: A new DNS provider for DNSPod. -### Changed: +### Changed + - lib: Exported the `PreCheckDNS` field so library users can manage the DNS check in tests. - lib: The library will now skip challenge solving if a valid Authz already exists. -### Removed: +### Removed + - lib: The library will no longer check for auto-renewed certificates. This has been removed from the spec and is not supported in Boulder. -### Fixed: +### Fixed + - lib: Fix a problem with the Route53 provider where it was possible the verification was published to a private zone. - lib: Loading an account from file should fail if an integral part is nil - lib: Fix a potential issue where the Dyn provider could resolve to an incorrect zone. @@ -1163,20 +1841,28 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated. - lib: Fixed a condition where we could stall due to an early error condition. - lib: Fixed an issue where Authz object could end up in an active state after an error condition. -## [0.3.1] - 2016-04-19 +## 0.3.1 + +- Release date: 2016-04-19 +- Tag: [0.3.1](https://github.com/go-acme/lego/releases/tag/0.3.1) + +### Added -### Added: - lib: A new DNS provider for Vultr. -### Fixed: +### Fixed + - lib: DNS Provider for DigitalOcean could not handle subdomains properly. - lib: handleHTTPError should only try to JSON decode error messages with the right content type. - lib: The propagation checker for the DNS challenge would not retry on send errors. +## 0.3.0 -## [0.3.0] - 2016-03-19 +- Release date: 2016-03-19 +- Tag: [0.3.0](https://github.com/go-acme/lego/releases/tag/0.3.0) + +### Added -### Added: - CLI: The `--dns` switch. To include the DNS challenge for consideration. When using this switch, all other solvers are disabled. Supported are the following solvers: cloudflare, digitalocean, dnsimple, dyn, gandi, googlecloud, namecheap, route53, rfc2136 and manual. - CLI: The `--accept-tos` switch. Indicates your acceptance of the Let's Encrypt terms of service without prompting you. - CLI: The `--webroot` switch. The HTTP-01 challenge may now be completed by dropping a file into a webroot. When using this switch, all other solvers are disabled. @@ -1191,6 +1877,7 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated. - lib: The `acme.KeyType` type was added and is used for the configuration of crypto parameters for RSA and EC keys. Valid KeyTypes are: EC256, EC384, RSA2048, RSA4096 and RSA8192. ### Changed + - lib: ExcludeChallenges now expects to be passed an array of `Challenge` types. - lib: HTTP-01 now supports custom solvers using the `ChallengeProvider` interface. - lib: TLS-SNI-01 now supports custom solvers using the `ChallengeProvider` interface. @@ -1198,16 +1885,22 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated. - lib: The `acme.NewClient` function now expects an `acme.KeyType` instead of the keyBits parameter. ### Removed + - CLI: The `rsa-key-size` switch was removed in favor of `key-type` to support EC keys. ### Fixed + - lib: Fixed a race condition in HTTP-01 - lib: Fixed an issue where status codes on ACME challenge responses could lead to no action being taken. - lib: Fixed a regression when calling the Renew function with a SAN certificate. -## [0.2.0] - 2016-01-09 +## 0.2.0 + +- Release date: 2016-01-09 +- Tag: [0.2.0](https://github.com/go-acme/lego/releases/tag/0.2.0) + +### Added -### Added: - CLI: The `--exclude` or `-x` switch. To exclude a challenge from being solved. - CLI: The `--http` switch. To set the listen address and port of HTTP based challenges. Supports `host:port` and `:port` for any interface. - CLI: The `--tls` switch. To set the listen address and port of TLS based challenges. Supports `host:port` and `:port` for any interface. @@ -1217,35 +1910,49 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated. - lib: SetTLSAddress function. Pass a port to set the listen port of TLS based challenges. - lib: acme.UserAgent variable. Use this to customize the user agent on all requests sent by lego. -### Changed: +### Changed + - lib: NewClient does no longer accept the optPort parameter - lib: ObtainCertificate now returns a SAN certificate if you pass more than one domain. - lib: GetOCSPForCert now returns the parsed OCSP response instead of just the status. - lib: ObtainCertificate has a new parameter `privKey crypto.PrivateKey` which lets you reuse an existing private key for new certificates. - lib: RenewCertificate now expects the PrivateKey property of the CertificateResource to be set only if you want to reuse the key. -### Removed: +### Removed + - CLI: The `--port` switch was removed. - lib: RenewCertificate does no longer offer to also revoke your old certificate. -### Fixed: +### Fixed + - CLI: Fix logic using the `--days` parameter for renew -## [0.1.1] - 2015-12-18 +## 0.1.1 + +- Release date: 2015-12-18 +- Tag: [0.1.1](https://github.com/go-acme/lego/releases/tag/0.1.1) + +### Added -### Added: - CLI: Added a way to automate renewal through a cronjob using the --days parameter to renew -### Changed: +### Changed + - lib: Improved log output on challenge failures. -### Fixed: +### Fixed + - CLI: The short parameter for domains would not get accepted - CLI: The cli did not return proper exit codes on error library errors. - lib: RenewCertificate did not properly renew SAN certificates. ### Security + - lib: Fix possible DOS on GetOCSPForCert -## [0.1.0] - 2015-12-03 -- Initial release +## 0.1.0 + +- Release date: 2015-12-03 +- Tag: [0.1.0](https://github.com/go-acme/lego/releases/tag/0.1.0) + +Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a0005cff8..05e4fa994 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ To ensure a great and easy experience for everyone, please review the few guidel - If both of the above do not apply, create a new issue and include as much information as possible. Bug reports should include all information a person could need to reproduce your problem without the need to -follow up for more information. If possible, provide detailed steps for us to reproduce it, the expected behaviour and the actual behaviour. +follow up for more information. If possible, provide detailed steps for us to reproduce it, the expected behavior and the actual behavior. ## Feature proposals and requests @@ -20,31 +20,26 @@ It is up to you to make a strong point about your proposal and convince us of th ## Pull requests +Create an issue and wait for a maintainer to approve it BEFORE opening a pull request. + Patches, new features and improvements are a great way to help the project. Please keep them focused on one thing and do not include unrelated commits. -All pull requests which alter the behaviour of the program, add new behaviour or somehow alter code in a non-trivial way should **always** include tests. +All pull requests that alter the behavior of the program, +add new behavior or somehow alter code in a non-trivial way should **always** include tests. -If you want to contribute a significant pull request (with a non-trivial workload for you) please **ask first**. We do not want you to spend -a lot of time on something the project's developers might not want to merge into the project. - -**IMPORTANT**: By submitting a patch, you agree to allow the project -owners to license your work under the terms of the [MIT License](LICENSE). +**IMPORTANT**: By submitting a patch, you agree to allow the project owners to license your work under the terms of the [MIT License](LICENSE). ### How to create a pull request Requirements: -- `go` v1.15+ +- `go` v1.24+ - environment variable: `GO111MODULE=on` First, you have to install [GoLang](https://golang.org/doc/install) and [golangci-lint](https://github.com/golangci/golangci-lint#install). ```bash -# Create the root folder -mkdir -p $GOPATH/src/github.com/go-acme -cd $GOPATH/src/github.com/go-acme - # clone your fork git clone git@github.com:YOUR_USERNAME/lego.git cd lego @@ -56,14 +51,12 @@ git fetch upstream ```bash # Create your branch -git checkout -b my-feature +git switch -c my-feature ## Create your code ## ``` ```bash -# Format -make fmt # Linters make checks # Tests diff --git a/Makefile b/Makefile index 28cb33908..8536dfc40 100644 --- a/Makefile +++ b/Makefile @@ -54,10 +54,10 @@ detach: .PHONY: docs-build docs-serve docs-themes docs-build: generate-dns - @make -C ./docs hugo-build + @make -C ./docs build docs-serve: generate-dns - @make -C ./docs hugo + @make -C ./docs serve docs-themes: @make -C ./docs hugo-themes diff --git a/README.md b/README.md index a430446c3..e90e94962 100644 --- a/README.md +++ b/README.md @@ -5,29 +5,36 @@ # Lego -Let's Encrypt client and ACME library written in Go. +[ACME](https://www.rfc-editor.org/rfc/rfc8555.html) client and library for Let's Encrypt and other ACME CAs written in Go. [![Go Reference](https://pkg.go.dev/badge/github.com/go-acme/lego/v4.svg)](https://pkg.go.dev/github.com/go-acme/lego/v4) [![Build Status](https://github.com//go-acme/lego/workflows/Main/badge.svg?branch=master)](https://github.com//go-acme/lego/actions) [![Docker Pulls](https://img.shields.io/docker/pulls/goacme/lego.svg)](https://hub.docker.com/r/goacme/lego/) +lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️ + +Everybody thinks that the others will donate, but in the end, nobody does. + +So if you think that lego is worth it, please consider [donating](https://donate.ldez.dev). + ## Features - ACME v2 [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555.html) - Support [RFC 8737](https://www.rfc-editor.org/rfc/rfc8737.html): TLS Application‑Layer Protocol Negotiation (ALPN) Challenge Extension - Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): certificates for IP addresses - - Support [draft-ietf-acme-ari-03](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension + - Support [RFC 9773](https://www.rfc-editor.org/rfc/rfc9773.html): Renewal Information (ARI) Extension + - Support [draft-ietf-acme-profiles-00](https://datatracker.ietf.org/doc/draft-ietf-acme-profiles/): Profiles Extension +- Comes with about [180 DNS providers](https://go-acme.github.io/lego/dns) - Register with CA - Obtain certificates, both from scratch or with an existing CSR - Renew certificates - Revoke certificates -- Robust implementation of all ACME challenges +- Robust implementation of ACME challenges: - HTTP (http-01) - DNS (dns-01) - TLS (tls-alpn-01) - SAN certificate support - [CNAME support](https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme.html) by default -- Comes with multiple optional [DNS providers](https://go-acme.github.io/lego/dns) - [Custom challenge solvers](https://go-acme.github.io/lego/usage/library/writing-a-challenge-solver/) - Certificate bundling - OCSP helper function @@ -49,77 +56,114 @@ Documentation is hosted live at https://go-acme.github.io/lego/. Detailed documentation is available [here](https://go-acme.github.io/lego/dns). +If your DNS provider is not supported, please open an [issue](https://github.com/go-acme/lego/issues/new?assignees=&labels=enhancement%2C+new-provider&template=new_dns_provider.yml). + + + + + + + + + + + + + + + + + + + - + - - + + + - + + + - + - + + + - - - + + + + + - + + + + + + + - + + + + + @@ -138,49 +182,64 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). + - + + + + + + + + - + - + + + + - + + + - + - + + - + + @@ -188,51 +247,61 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). + - + - + - - + + + + + + + - + - + + + - - - + + + + + + -
35.com/三五互联Active24 Akamai EdgeDNS Alibaba Cloud DNS
AlibabaCloud ESA all-inklAlwaysdata Amazon Lightsail
Amazon Route 53Anexia CloudDNSANS SafeDNSArtFiles
ArvanCloud Aurora DNS AutodnsAxelname
Azion Azure (deprecated) Azure DNSBaidu Cloud
Beget.comBinary Lane Bindman Bluecat
Bluecat v2BookMyName Brandit (deprecated) Bunny
Checkdomain Civo
Cloud.ru CloudDNS
Cloudflare ClouDNS
CloudXNS (Deprecated)ConoHaConoHa v2
ConoHa v3 Constellix Core-Networks
CPanel/WHM
CzechiaDDnss (DynDNS Service) Derak Cloud deSEC.ioDesignate DNSaaS for Openstack
Designate DNSaaS for Openstack Digital Ocean DirectAdmin DNS Made EasydnsHome.de
DNSExitdnsHome.de DNSimple DNSPod (deprecated)
Domain Offensive (do.de) Domeneshop
DreamHost Duck DNSDynDynu
DynDynDnsFree.deDynu EasyDNS
EdgeCenter Efficient IP EpikExoscaleEuroDNS
ExcedoExoscale External programF5 XC
freemyip.comFusionLayer NameSurfer G-Core Gandi
Gandi Live DNS (v5)Gigahost.no Glesys Go DaddyGoogle Cloud
Google Cloud Google DomainsGravity Hetzner
Hosting.deHosting.nlHostinger Hosttech
HTTP requestINWX
IonosIonos Cloud IPv64iwantmynameISPConfig 3
ISPConfig 3 - Dynamic DNS (DDNS) Moduleiwantmyname (Deprecated)JD Cloud Joker
Joohoi's ACME-DNSKeyHelpLeaseweb Liara
Lima-City Linode (v4)
Liquid Web Loopia
LuaDNS Mail-in-a-Box
ManageEngine CloudDNS Manual
MetanameMetaregistrar mijn.host Mittwald
myaddr.{tools,dev,io} MyDNS.jp MythicBeasts Name.comNamecheap
Namecheap Namesilo NearlyFreeSpeech.NETNeodigit
Netcup Netlify
Nicmanager NIFCloud
Njalla Nodion
NS1Octenium
Open Telekom Cloud Oracle Cloud OVH
plesk.com
Porkbun PowerDNS RackspaceRain Yun/雨云
RcodeZero reg.ruRFC2136
RimuHostingRU CENTER Sakura Cloud ScalewaySelectel
Selectel Selectel v2 SelfHost.(de|eu) ServercowShellrent
Shellrent Simply.com SonicStackpathTechnitiumSpaceship
StackpathSyseTechnitium Tencent Cloud DNS
Tencent EdgeOne Timeweb CloudTodayNIC/时代互联 TransIPUKFast SafeDNS
UltradnsUnited-Domains Variomedia VegaDNSVercel
Vercel Versio.[nl|eu|uk] VinylDNSVirtualname
VK Cloud Volcano Engine/火山引擎
Vscale VultrWebnamesWebsupport
webnames.cawebnames.ruWebsupport WEDOS
West.cn/西部数码 Yandex 360 Yandex Cloud Yandex PDD
Zone.eeZoneEdit Zonomi
-If your DNS provider is not supported, please open an [issue](https://github.com/go-acme/lego/issues/new?assignees=&labels=enhancement%2C+new-provider&template=new_dns_provider.md). +If your DNS provider is not supported, please open an [issue](https://github.com/go-acme/lego/issues/new?assignees=&labels=enhancement%2C+new-provider&template=new_dns_provider.yml). diff --git a/acme/api/account.go b/acme/api/account.go index 85de84ef3..62e5ef9a6 100644 --- a/acme/api/account.go +++ b/acme/api/account.go @@ -13,6 +13,7 @@ type AccountService service // New Creates a new account. func (a *AccountService) New(req acme.Account) (acme.ExtendedAccount, error) { var account acme.Account + resp, err := a.core.post(a.core.GetDirectory().NewAccountURL, req, &account) location := getLocation(resp) @@ -29,9 +30,9 @@ func (a *AccountService) New(req acme.Account) (acme.ExtendedAccount, error) { // NewEAB Creates a new account with an External Account Binding. func (a *AccountService) NewEAB(accMsg acme.Account, kid, hmacEncoded string) (acme.ExtendedAccount, error) { - hmac, err := base64.RawURLEncoding.DecodeString(hmacEncoded) + hmac, err := decodeEABHmac(hmacEncoded) if err != nil { - return acme.ExtendedAccount{}, fmt.Errorf("acme: could not decode hmac key: %w", err) + return acme.ExtendedAccount{}, err } eabJWS, err := a.core.signEABContent(a.core.GetDirectory().NewAccountURL, kid, hmac) @@ -51,10 +52,12 @@ func (a *AccountService) Get(accountURL string) (acme.Account, error) { } var account acme.Account + _, err := a.core.postAsGet(accountURL, &account) if err != nil { return acme.Account{}, err } + return account, nil } @@ -65,6 +68,7 @@ func (a *AccountService) Update(accountURL string, req acme.Account) (acme.Accou } var account acme.Account + _, err := a.core.post(accountURL, req, &account) if err != nil { return acme.Account{}, err @@ -81,5 +85,20 @@ func (a *AccountService) Deactivate(accountURL string) error { req := acme.Account{Status: acme.StatusDeactivated} _, err := a.core.post(accountURL, req, nil) + return err } + +func decodeEABHmac(hmacEncoded string) ([]byte, error) { + hmac, errRaw := base64.RawURLEncoding.DecodeString(hmacEncoded) + if errRaw == nil { + return hmac, nil + } + + hmac, err := base64.URLEncoding.DecodeString(hmacEncoded) + if err == nil { + return hmac, nil + } + + return nil, fmt.Errorf("acme: could not decode hmac key: %w", errors.Join(errRaw, err)) +} diff --git a/acme/api/account_test.go b/acme/api/account_test.go new file mode 100644 index 000000000..16bd80741 --- /dev/null +++ b/acme/api/account_test.go @@ -0,0 +1,35 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_decodeEABHmac(t *testing.T) { + testCases := []struct { + desc string + hmac string + }{ + { + desc: "RawURLEncoding", + hmac: "BAEDAgQCBQcGCAUDDDMBAAIRAwQhEjEFQVFhEyJxgTIGFJGhsUIjJBVSwWIzNHKC0UMHJZJT8OHx", + }, + { + desc: "URLEncoding", + hmac: "nKTo9Hu8fpCqWPXx-25LVbZrJWxcHISsr4qHrRR0j5U=", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + v, err := decodeEABHmac(test.hmac) + require.NoError(t, err) + + assert.NotEmpty(t, v) + }) + } +} diff --git a/acme/api/api.go b/acme/api/api.go index b8c9cf0c9..da1c94d1b 100644 --- a/acme/api/api.go +++ b/acme/api/api.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "context" "crypto" "encoding/json" "errors" @@ -9,7 +10,7 @@ import ( "net/http" "time" - "github.com/cenkalti/backoff/v4" + "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api/internal/nonces" "github.com/go-acme/lego/v4/acme/api/internal/secure" @@ -60,7 +61,7 @@ func New(httpClient *http.Client, userAgent, caDirURL, kid string, privateKey cr // post performs an HTTP POST request and parses the response body as JSON, // into the provided respBody object. -func (a *Core) post(uri string, reqBody, response interface{}) (*http.Response, error) { +func (a *Core) post(uri string, reqBody, response any) (*http.Response, error) { content, err := json.Marshal(reqBody) if err != nil { return nil, errors.New("failed to marshal message") @@ -71,47 +72,44 @@ func (a *Core) post(uri string, reqBody, response interface{}) (*http.Response, // postAsGet performs an HTTP POST ("POST-as-GET") request. // https://www.rfc-editor.org/rfc/rfc8555.html#section-6.3 -func (a *Core) postAsGet(uri string, response interface{}) (*http.Response, error) { +func (a *Core) postAsGet(uri string, response any) (*http.Response, error) { return a.retrievablePost(uri, []byte{}, response) } -func (a *Core) retrievablePost(uri string, content []byte, response interface{}) (*http.Response, error) { +func (a *Core) retrievablePost(uri string, content []byte, response any) (*http.Response, error) { + ctx := context.Background() + // during tests, allow to support ~90% of bad nonce with a minimum of attempts. bo := backoff.NewExponentialBackOff() bo.InitialInterval = 200 * time.Millisecond bo.MaxInterval = 5 * time.Second - bo.MaxElapsedTime = 20 * time.Second - var resp *http.Response - operation := func() error { - var err error - resp, err = a.signedPost(uri, content, response) + operation := func() (*http.Response, error) { + resp, err := a.signedPost(uri, content, response) if err != nil { // Retry if the nonce was invalidated var e *acme.NonceError if errors.As(err, &e) { - return err + return resp, err } - return backoff.Permanent(err) + return resp, backoff.Permanent(err) } - return nil + return resp, nil } notify := func(err error, duration time.Duration) { log.Infof("retry due to: %v", err) } - err := backoff.RetryNotify(operation, bo, notify) - if err != nil { - return resp, err - } - - return resp, nil + return backoff.Retry(ctx, operation, + backoff.WithBackOff(bo), + backoff.WithMaxElapsedTime(20*time.Second), + backoff.WithNotify(notify)) } -func (a *Core) signedPost(uri string, content []byte, response interface{}) (*http.Response, error) { +func (a *Core) signedPost(uri string, content []byte, response any) (*http.Response, error) { signedContent, err := a.jws.SignContent(uri, content) if err != nil { return nil, fmt.Errorf("failed to post JWS message: failed to sign content: %w", err) @@ -157,6 +155,7 @@ func getDirectory(do *sender.Doer, caDirURL string) (acme.Directory, error) { if dir.NewAccountURL == "" { return dir, errors.New("directory missing new registration URL") } + if dir.NewOrderURL == "" { return dir, errors.New("directory missing new order URL") } diff --git a/acme/api/authorization.go b/acme/api/authorization.go index a9972aa94..4195bd1fe 100644 --- a/acme/api/authorization.go +++ b/acme/api/authorization.go @@ -15,10 +15,12 @@ func (c *AuthorizationService) Get(authzURL string) (acme.Authorization, error) } var authz acme.Authorization + _, err := c.core.postAsGet(authzURL, &authz) if err != nil { return acme.Authorization{}, err } + return authz, nil } @@ -29,6 +31,8 @@ func (c *AuthorizationService) Deactivate(authzURL string) error { } var disabledAuth acme.Authorization + _, err := c.core.post(authzURL, acme.Authorization{Status: acme.StatusDeactivated}, &disabledAuth) + return err } diff --git a/acme/api/certificate.go b/acme/api/certificate.go index 5f31968cf..b42296768 100644 --- a/acme/api/certificate.go +++ b/acme/api/certificate.go @@ -2,15 +2,12 @@ package api import ( "bytes" - "crypto/x509" "encoding/pem" "errors" "io" "net/http" "github.com/go-acme/lego/v4/acme" - "github.com/go-acme/lego/v4/certcrypto" - "github.com/go-acme/lego/v4/log" ) // maxBodySize is the maximum size of body that we will read. @@ -77,62 +74,22 @@ func (c *CertificateService) get(certURL string, bundle bool) (*acme.RawCertific return nil, resp.Header, err } - cert := c.getCertificateChain(data, resp.Header, bundle, certURL) + cert := c.getCertificateChain(data, bundle) return cert, resp.Header, err } // getCertificateChain Returns the certificate and the issuer certificate. -func (c *CertificateService) getCertificateChain(cert []byte, headers http.Header, bundle bool, certURL string) *acme.RawCertificate { +func (c *CertificateService) getCertificateChain(cert []byte, bundle bool) *acme.RawCertificate { // Get issuerCert from bundled response from Let's Encrypt // See https://community.letsencrypt.org/t/acme-v2-no-up-link-in-response/64962 _, issuer := pem.Decode(cert) - if issuer != nil { - // If bundle is false, we want to return a single certificate. - // To do this, we remove the issuer cert(s) from the issued cert. - if !bundle { - cert = bytes.TrimSuffix(cert, issuer) - } - return &acme.RawCertificate{Cert: cert, Issuer: issuer} - } - // The issuer certificate link may be supplied via an "up" link - // in the response headers of a new certificate. - // See https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4.2 - up := getLink(headers, "up") - - issuer, err := c.getIssuerFromLink(up) - if err != nil { - // If we fail to acquire the issuer cert, return the issued certificate - do not fail. - log.Warnf("acme: Could not bundle issuer certificate [%s]: %v", certURL, err) - } else if len(issuer) > 0 { - // If bundle is true, we want to return a certificate bundle. - // To do this, we append the issuer cert to the issued cert. - if bundle { - cert = append(cert, issuer...) - } + // If bundle is false, we want to return a single certificate. + // To do this, we remove the issuer cert(s) from the issued cert. + if !bundle { + cert = bytes.TrimSuffix(cert, issuer) } return &acme.RawCertificate{Cert: cert, Issuer: issuer} } - -// getIssuerFromLink requests the issuer certificate. -func (c *CertificateService) getIssuerFromLink(up string) ([]byte, error) { - if up == "" { - return nil, nil - } - - log.Infof("acme: Requesting issuer cert from %s", up) - - cert, _, err := c.get(up, false) - if err != nil { - return nil, err - } - - _, err = x509.ParseCertificate(cert.Cert) - if err != nil { - return nil, err - } - - return certcrypto.PEMEncode(certcrypto.DERCertificateBytes(cert.Cert)), nil -} diff --git a/acme/api/certificate_test.go b/acme/api/certificate_test.go index 9776cccc5..7220ca1b9 100644 --- a/acme/api/certificate_test.go +++ b/acme/api/certificate_test.go @@ -3,11 +3,10 @@ package api import ( "crypto/rand" "crypto/rsa" - "encoding/pem" - "net/http" "testing" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -74,56 +73,34 @@ rzFL1KZfz+HZdnFwFW2T2gVW8L3ii1l9AJDuKzlvjUH3p6bgihVq02sjT8mx+GM2 ` func TestCertificateService_Get_issuerRelUp(t *testing.T) { - mux, apiURL := tester.SetupFakeAPI(t) - - mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Link", "<"+apiURL+`/issuer>; rel="up"`) - _, err := w.Write([]byte(certResponseMock)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - mux.HandleFunc("/issuer", func(w http.ResponseWriter, _ *http.Request) { - p, _ := pem.Decode([]byte(issuerMock)) - _, err := w.Write(p.Bytes) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + server := tester.MockACMEServer(). + Route("POST /certificate", servermock.RawStringResponse(certResponseMock)). + BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") - core, err := New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key) + core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) - cert, issuer, err := core.Certificates.Get(apiURL+"/certificate", true) + cert, issuer, err := core.Certificates.Get(server.URL+"/certificate", true) require.NoError(t, err) assert.Equal(t, certResponseMock, string(cert), "Certificate") assert.Equal(t, issuerMock, string(issuer), "IssuerCertificate") } func TestCertificateService_Get_embeddedIssuer(t *testing.T) { - mux, apiURL := tester.SetupFakeAPI(t) - - mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) { - _, err := w.Write([]byte(certResponseMock)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + server := tester.MockACMEServer(). + Route("POST /certificate", servermock.RawStringResponse(certResponseMock)). + BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") - core, err := New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key) + core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) - cert, issuer, err := core.Certificates.Get(apiURL+"/certificate", true) + cert, issuer, err := core.Certificates.Get(server.URL+"/certificate", true) require.NoError(t, err) assert.Equal(t, certResponseMock, string(cert), "Certificate") assert.Equal(t, issuerMock, string(issuer), "IssuerCertificate") diff --git a/acme/api/challenge.go b/acme/api/challenge.go index 875dede6e..2af55fc1a 100644 --- a/acme/api/challenge.go +++ b/acme/api/challenge.go @@ -17,6 +17,7 @@ func (c *ChallengeService) New(chlgURL string) (acme.ExtendedChallenge, error) { // Challenge initiation is done by sending a JWS payload containing the trivial JSON object `{}`. // We use an empty struct instance as the postJSON payload here to achieve this result. var chlng acme.ExtendedChallenge + resp, err := c.core.post(chlgURL, struct{}{}, &chlng) if err != nil { return acme.ExtendedChallenge{}, err @@ -24,6 +25,7 @@ func (c *ChallengeService) New(chlgURL string) (acme.ExtendedChallenge, error) { chlng.AuthorizationURL = getLink(resp.Header, "up") chlng.RetryAfter = getRetryAfter(resp) + return chlng, nil } @@ -34,6 +36,7 @@ func (c *ChallengeService) Get(chlgURL string) (acme.ExtendedChallenge, error) { } var chlng acme.ExtendedChallenge + resp, err := c.core.postAsGet(chlgURL, &chlng) if err != nil { return acme.ExtendedChallenge{}, err @@ -41,5 +44,6 @@ func (c *ChallengeService) Get(chlgURL string) (acme.ExtendedChallenge, error) { chlng.AuthorizationURL = getLink(resp.Header, "up") chlng.RetryAfter = getRetryAfter(resp) + return chlng, nil } diff --git a/acme/api/identifier.go b/acme/api/identifier.go new file mode 100644 index 000000000..245ed8515 --- /dev/null +++ b/acme/api/identifier.go @@ -0,0 +1,52 @@ +package api + +import ( + "cmp" + "net" + "slices" + + "github.com/go-acme/lego/v4/acme" +) + +func createIdentifiers(domains []string) []acme.Identifier { + uniqIdentifiers := make(map[string]struct{}) + + var identifiers []acme.Identifier + + for _, domain := range domains { + if _, ok := uniqIdentifiers[domain]; ok { + continue + } + + ident := acme.Identifier{Value: domain, Type: "dns"} + + if net.ParseIP(domain) != nil { + ident.Type = "ip" + } + + identifiers = append(identifiers, ident) + + uniqIdentifiers[domain] = struct{}{} + } + + return identifiers +} + +// compareIdentifiers compares 2 slices of [acme.Identifier]. +func compareIdentifiers(a, b []acme.Identifier) int { + // Clones slices to avoid modifying original slices. + right := slices.Clone(a) + left := slices.Clone(b) + + slices.SortStableFunc(right, compareIdentifier) + slices.SortStableFunc(left, compareIdentifier) + + return slices.CompareFunc(right, left, compareIdentifier) +} + +func compareIdentifier(right, left acme.Identifier) int { + return cmp.Or( + cmp.Compare(right.Type, left.Type), + cmp.Compare(right.Value, left.Value), + ) +} diff --git a/acme/api/identifier_test.go b/acme/api/identifier_test.go new file mode 100644 index 000000000..586a87986 --- /dev/null +++ b/acme/api/identifier_test.go @@ -0,0 +1,111 @@ +package api + +import ( + "testing" + + "github.com/go-acme/lego/v4/acme" + "github.com/stretchr/testify/assert" +) + +func Test_compareIdentifiers(t *testing.T) { + testCases := []struct { + desc string + a, b []acme.Identifier + expected int + }{ + { + desc: "identical identifiers", + a: []acme.Identifier{ + {Type: "dns", Value: "example.com"}, + {Type: "dns", Value: "*.example.com"}, + }, + b: []acme.Identifier{ + {Type: "dns", Value: "example.com"}, + {Type: "dns", Value: "*.example.com"}, + }, + expected: 0, + }, + { + desc: "identical identifiers but different order", + a: []acme.Identifier{ + {Type: "dns", Value: "example.com"}, + {Type: "dns", Value: "*.example.com"}, + }, + b: []acme.Identifier{ + {Type: "dns", Value: "*.example.com"}, + {Type: "dns", Value: "example.com"}, + }, + expected: 0, + }, + { + desc: "duplicate identifiers", + a: []acme.Identifier{ + {Type: "dns", Value: "example.com"}, + {Type: "dns", Value: "*.example.com"}, + }, + b: []acme.Identifier{ + {Type: "dns", Value: "example.com"}, + {Type: "dns", Value: "example.com"}, + }, + expected: -1, + }, + { + desc: "different identifier values", + a: []acme.Identifier{ + {Type: "dns", Value: "example.com"}, + {Type: "dns", Value: "*.example.com"}, + }, + b: []acme.Identifier{ + {Type: "dns", Value: "example.com"}, + {Type: "dns", Value: "*.example.org"}, + }, + expected: -1, + }, + { + desc: "different identifier types", + a: []acme.Identifier{ + {Type: "dns", Value: "example.com"}, + {Type: "dns", Value: "*.example.com"}, + }, + b: []acme.Identifier{ + {Type: "dns", Value: "example.com"}, + {Type: "ip", Value: "*.example.com"}, + }, + expected: -1, + }, + { + desc: "different number of identifiers a>b", + a: []acme.Identifier{ + {Type: "dns", Value: "example.com"}, + {Type: "dns", Value: "*.example.com"}, + {Type: "dns", Value: "example.org"}, + }, + b: []acme.Identifier{ + {Type: "dns", Value: "example.com"}, + {Type: "dns", Value: "*.example.com"}, + }, + expected: 1, + }, + { + desc: "different number of identifiers b>a", + a: []acme.Identifier{ + {Type: "dns", Value: "example.com"}, + {Type: "dns", Value: "*.example.com"}, + }, + b: []acme.Identifier{ + {Type: "dns", Value: "example.com"}, + {Type: "dns", Value: "*.example.com"}, + {Type: "dns", Value: "example.org"}, + }, + expected: -1, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.expected, compareIdentifiers(test.a, test.b)) + }) + } +} diff --git a/acme/api/internal/nonces/nonce_manager.go b/acme/api/internal/nonces/nonce_manager.go index d089cf07c..04a4ac620 100644 --- a/acme/api/internal/nonces/nonce_manager.go +++ b/acme/api/internal/nonces/nonce_manager.go @@ -11,10 +11,11 @@ import ( // Manager Manages nonces. type Manager struct { + sync.Mutex + do *sender.Doer nonceURL string nonces []string - sync.Mutex } // NewManager Creates a new Manager. @@ -36,6 +37,7 @@ func (n *Manager) Pop() (string, bool) { nonce := n.nonces[len(n.nonces)-1] n.nonces = n.nonces[:len(n.nonces)-1] + return nonce, true } @@ -43,6 +45,7 @@ func (n *Manager) Pop() (string, bool) { func (n *Manager) Push(nonce string) { n.Lock() defer n.Unlock() + n.nonces = append(n.nonces, nonce) } @@ -51,6 +54,7 @@ func (n *Manager) Nonce() (string, error) { if nonce, ok := n.Pop(); ok { return nonce, nil } + return n.getNonce() } diff --git a/acme/api/internal/nonces/nonce_manager_test.go b/acme/api/internal/nonces/nonce_manager_test.go index a172a0b69..4490165df 100644 --- a/acme/api/internal/nonces/nonce_manager_test.go +++ b/acme/api/internal/nonces/nonce_manager_test.go @@ -8,45 +8,52 @@ import ( "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api/internal/sender" - "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" ) func TestNotHoldingLockWhileMakingHTTPRequests(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - time.Sleep(250 * time.Millisecond) - w.Header().Set("Replay-Nonce", "12345") - w.Header().Set("Retry-After", "0") - err := tester.WriteJSONResponse(w, &acme.Challenge{Type: "http-01", Status: "Valid", URL: "http://example.com/", Token: "token"}) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - })) - t.Cleanup(server.Close) + manager := servermock.NewBuilder( + func(server *httptest.Server) (*Manager, error) { + doer := sender.NewDoer(server.Client(), "lego-test") + + return NewManager(doer, server.URL), nil + }). + Route("HEAD /", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + time.Sleep(250 * time.Millisecond) + + rw.Header().Set("Replay-Nonce", "12345") + rw.Header().Set("Retry-After", "0") + + servermock.JSONEncode(&acme.Challenge{Type: "http-01", Status: "Valid", URL: "https://example.com/", Token: "token"}).ServeHTTP(rw, req) + })). + BuildHTTPS(t) - doer := sender.NewDoer(http.DefaultClient, "lego-test") - j := NewManager(doer, server.URL) ch := make(chan bool) resultCh := make(chan bool) + go func() { - _, errN := j.Nonce() + _, errN := manager.Nonce() if errN != nil { t.Log(errN) } + ch <- true }() go func() { - _, errN := j.Nonce() + _, errN := manager.Nonce() if errN != nil { t.Log(errN) } + ch <- true }() go func() { <-ch <-ch + resultCh <- true }() + select { case <-resultCh: case <-time.After(500 * time.Millisecond): diff --git a/acme/api/internal/secure/jws.go b/acme/api/internal/secure/jws.go index 8afd44676..8cd598663 100644 --- a/acme/api/internal/secure/jws.go +++ b/acme/api/internal/secure/jws.go @@ -36,6 +36,7 @@ func (j *JWS) SetKid(kid string) { // SignContent Signs a content with the JWS. func (j *JWS) SignContent(url string, content []byte) (*jose.JSONWebSignature, error) { var alg jose.SignatureAlgorithm + switch k := j.privKey.(type) { case *rsa.PrivateKey: alg = jose.RS256 @@ -54,7 +55,7 @@ func (j *JWS) SignContent(url string, content []byte) (*jose.JSONWebSignature, e options := jose.SignerOptions{ NonceSource: j.nonces, - ExtraHeaders: map[jose.HeaderKey]interface{}{ + ExtraHeaders: map[jose.HeaderKey]any{ "url": url, }, } @@ -72,12 +73,14 @@ func (j *JWS) SignContent(url string, content []byte) (*jose.JSONWebSignature, e if err != nil { return nil, fmt.Errorf("failed to sign content: %w", err) } + return signed, nil } // SignEABContent Signs an external account binding content with the JWS. func (j *JWS) SignEABContent(url, kid string, hmac []byte) (*jose.JSONWebSignature, error) { jwk := jose.JSONWebKey{Key: j.privKey} + jwkJSON, err := jwk.Public().MarshalJSON() if err != nil { return nil, fmt.Errorf("acme: error encoding eab jwk key: %w", err) @@ -87,7 +90,7 @@ func (j *JWS) SignEABContent(url, kid string, hmac []byte) (*jose.JSONWebSignatu jose.SigningKey{Algorithm: jose.HS256, Key: hmac}, &jose.SignerOptions{ EmbedJWK: false, - ExtraHeaders: map[jose.HeaderKey]interface{}{ + ExtraHeaders: map[jose.HeaderKey]any{ "kid": kid, "url": url, }, @@ -108,6 +111,7 @@ func (j *JWS) SignEABContent(url, kid string, hmac []byte) (*jose.JSONWebSignatu // GetKeyAuthorization Gets the key authorization for a token. func (j *JWS) GetKeyAuthorization(token string) (string, error) { var publicKey crypto.PublicKey + switch k := j.privKey.(type) { case *ecdsa.PrivateKey: publicKey = k.Public() diff --git a/acme/api/internal/secure/jws_test.go b/acme/api/internal/secure/jws_test.go index 2e625f24f..d033cb0c4 100644 --- a/acme/api/internal/secure/jws_test.go +++ b/acme/api/internal/secure/jws_test.go @@ -9,45 +9,52 @@ import ( "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api/internal/nonces" "github.com/go-acme/lego/v4/acme/api/internal/sender" - "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" ) func TestNotHoldingLockWhileMakingHTTPRequests(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - time.Sleep(250 * time.Millisecond) - w.Header().Set("Replay-Nonce", "12345") - w.Header().Set("Retry-After", "0") - err := tester.WriteJSONResponse(w, &acme.Challenge{Type: "http-01", Status: "Valid", URL: "http://example.com/", Token: "token"}) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - })) - t.Cleanup(server.Close) + manager := servermock.NewBuilder( + func(server *httptest.Server) (*nonces.Manager, error) { + doer := sender.NewDoer(server.Client(), "lego-test") + + return nonces.NewManager(doer, server.URL), nil + }). + Route("HEAD /", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + time.Sleep(250 * time.Millisecond) + + rw.Header().Set("Replay-Nonce", "12345") + rw.Header().Set("Retry-After", "0") + + servermock.JSONEncode(&acme.Challenge{Type: "http-01", Status: "Valid", URL: "https://example.com/", Token: "token"}).ServeHTTP(rw, req) + })). + BuildHTTPS(t) - doer := sender.NewDoer(http.DefaultClient, "lego-test") - j := nonces.NewManager(doer, server.URL) ch := make(chan bool) resultCh := make(chan bool) + go func() { - _, errN := j.Nonce() + _, errN := manager.Nonce() if errN != nil { t.Log(errN) } + ch <- true }() go func() { - _, errN := j.Nonce() + _, errN := manager.Nonce() if errN != nil { t.Log(errN) } + ch <- true }() go func() { <-ch <-ch + resultCh <- true }() + select { case <-resultCh: case <-time.After(500 * time.Millisecond): diff --git a/acme/api/internal/sender/sender.go b/acme/api/internal/sender/sender.go index 29cd7c9be..d8859edf4 100644 --- a/acme/api/internal/sender/sender.go +++ b/acme/api/internal/sender/sender.go @@ -27,6 +27,8 @@ type Doer struct { // NewDoer Creates a new Doer. func NewDoer(client *http.Client, userAgent string) *Doer { + client.Transport = newHTTPSOnly(client) + return &Doer{ httpClient: client, userAgent: userAgent, @@ -35,7 +37,7 @@ func NewDoer(client *http.Client, userAgent string) *Doer { // Get performs a GET request with a proper User-Agent string. // If "response" is not provided, callers should close resp.Body when done reading from it. -func (d *Doer) Get(url string, response interface{}) (*http.Response, error) { +func (d *Doer) Get(url string, response any) (*http.Response, error) { req, err := d.newRequest(http.MethodGet, url, nil) if err != nil { return nil, err @@ -57,7 +59,7 @@ func (d *Doer) Head(url string) (*http.Response, error) { // Post performs a POST request with a proper User-Agent string. // If "response" is not provided, callers should close resp.Body when done reading from it. -func (d *Doer) Post(url string, body io.Reader, bodyType string, response interface{}) (*http.Response, error) { +func (d *Doer) Post(url string, body io.Reader, bodyType string, response any) (*http.Response, error) { req, err := d.newRequest(http.MethodPost, url, body, contentType(bodyType)) if err != nil { return nil, err @@ -84,7 +86,7 @@ func (d *Doer) newRequest(method, uri string, body io.Reader, opts ...RequestOpt return req, nil } -func (d *Doer) do(req *http.Request, response interface{}) (*http.Response, error) { +func (d *Doer) do(req *http.Request, response any) (*http.Response, error) { resp, err := d.httpClient.Do(req) if err != nil { return nil, err @@ -118,31 +120,69 @@ func (d *Doer) formatUserAgent() string { } func checkError(req *http.Request, resp *http.Response) error { - if resp.StatusCode >= http.StatusBadRequest { - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("%d :: %s :: %s :: %w", resp.StatusCode, req.Method, req.URL, err) - } - - var errorDetails *acme.ProblemDetails - err = json.Unmarshal(body, &errorDetails) - if err != nil { - return fmt.Errorf("%d ::%s :: %s :: %w :: %s", resp.StatusCode, req.Method, req.URL, err, string(body)) - } - - errorDetails.Method = req.Method - errorDetails.URL = req.URL.String() - - if errorDetails.HTTPStatus == 0 { - errorDetails.HTTPStatus = resp.StatusCode - } - - // Check for errors we handle specifically - if errorDetails.HTTPStatus == http.StatusBadRequest && errorDetails.Type == acme.BadNonceErr { - return &acme.NonceError{ProblemDetails: errorDetails} + if resp.StatusCode < http.StatusBadRequest { + return nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("%d :: %s :: %s :: %w", resp.StatusCode, req.Method, req.URL, err) + } + + var errorDetails *acme.ProblemDetails + + err = json.Unmarshal(body, &errorDetails) + if err != nil { + return fmt.Errorf("%d ::%s :: %s :: %w :: %s", resp.StatusCode, req.Method, req.URL, err, string(body)) + } + + errorDetails.Method = req.Method + errorDetails.URL = req.URL.String() + + if errorDetails.HTTPStatus == 0 { + errorDetails.HTTPStatus = resp.StatusCode + } + + // Check for errors we handle specifically + switch { + case errorDetails.HTTPStatus == http.StatusBadRequest && errorDetails.Type == acme.BadNonceErr: + return &acme.NonceError{ProblemDetails: errorDetails} + + case errorDetails.HTTPStatus == http.StatusConflict && errorDetails.Type == acme.AlreadyReplacedErr: + return &acme.AlreadyReplacedError{ProblemDetails: errorDetails} + + case errorDetails.HTTPStatus == http.StatusTooManyRequests && errorDetails.Type == acme.RateLimitedErr: + return &acme.RateLimitedError{ + ProblemDetails: errorDetails, + RetryAfter: resp.Header.Get("Retry-After"), } + default: return errorDetails } - return nil +} + +type httpsOnly struct { + rt http.RoundTripper +} + +func newHTTPSOnly(client *http.Client) *httpsOnly { + if client.Transport == nil { + return &httpsOnly{rt: http.DefaultTransport} + } + + return &httpsOnly{rt: client.Transport} +} + +// RoundTrip ensure HTTPS is used. +// Each ACME function is accomplished by the client sending a sequence of HTTPS requests to the server [RFC2818], +// carrying JSON messages [RFC8259]. +// Use of HTTPS is REQUIRED. +// https://datatracker.ietf.org/doc/html/rfc8555#section-6.1 +func (r *httpsOnly) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.Scheme != "https" { + return nil, fmt.Errorf("HTTPS is required: %s", req.URL) + } + + return r.rt.RoundTrip(req) } diff --git a/acme/api/internal/sender/sender_test.go b/acme/api/internal/sender/sender_test.go index 2fd43c878..73701ab11 100644 --- a/acme/api/internal/sender/sender_test.go +++ b/acme/api/internal/sender/sender_test.go @@ -1,24 +1,28 @@ package sender import ( + "bytes" + "io" "net/http" "net/http/httptest" "strings" "testing" + "github.com/go-acme/lego/v4/acme" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDo_UserAgentOnAllHTTPMethod(t *testing.T) { var ua, method string - server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + + server := httptest.NewTLSServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { ua = r.Header.Get("User-Agent") method = r.Method })) t.Cleanup(server.Close) - doer := NewDoer(http.DefaultClient, "") + doer := NewDoer(server.Client(), "") testCases := []struct { method string @@ -60,8 +64,87 @@ func TestDo_CustomUserAgent(t *testing.T) { ua := doer.formatUserAgent() assert.Contains(t, ua, ourUserAgent) assert.Contains(t, ua, customUA) + if strings.HasSuffix(ua, " ") { t.Errorf("UA should not have trailing spaces; got '%s'", ua) } + assert.Len(t, strings.Split(ua, " "), 5) } + +func TestDo_failWithHTTP(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + t.Cleanup(server.Close) + + sender := NewDoer(server.Client(), "test") + + _, err := sender.Post(server.URL, strings.NewReader("data"), "text/plain", nil) + require.ErrorContains(t, err, "HTTPS is required: http://") +} + +func Test_checkError(t *testing.T) { + testCases := []struct { + desc string + resp *http.Response + assert func(t *testing.T, err error) + }{ + { + desc: "default", + resp: &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:example","detail":"message","status":404}`)), + }, + assert: errorAs[*acme.ProblemDetails], + }, + { + desc: "badNonce", + resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:badNonce","detail":"message","status":400}`)), + }, + assert: errorAs[*acme.NonceError], + }, + { + desc: "alreadyReplaced", + resp: &http.Response{ + StatusCode: http.StatusConflict, + Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:alreadyReplaced","detail":"message","status":409}`)), + }, + assert: errorAs[*acme.AlreadyReplacedError], + }, + { + desc: "rateLimited", + resp: &http.Response{ + StatusCode: http.StatusConflict, + Header: http.Header{ + "Retry-After": []string{"1"}, + }, + Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:rateLimited","detail":"message","status":429}`)), + }, + assert: errorAs[*acme.RateLimitedError], + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "https://example.com", nil) + + err := checkError(req, test.resp) + require.Error(t, err) + + pb := &acme.ProblemDetails{} + assert.ErrorAs(t, err, &pb) + + test.assert(t, err) + }) + } +} + +func errorAs[T error](t *testing.T, err error) { + t.Helper() + + var zero T + assert.ErrorAs(t, err, &zero) +} diff --git a/acme/api/internal/sender/useragent.go b/acme/api/internal/sender/useragent.go index 6f5d16d84..51a1b4770 100644 --- a/acme/api/internal/sender/useragent.go +++ b/acme/api/internal/sender/useragent.go @@ -4,10 +4,10 @@ package sender const ( // ourUserAgent is the User-Agent of this underlying library package. - ourUserAgent = "xenolf-acme/4.20.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/order.go b/acme/api/order.go index 5179d061a..fad6be2b8 100644 --- a/acme/api/order.go +++ b/acme/api/order.go @@ -3,7 +3,8 @@ package api import ( "encoding/base64" "errors" - "net" + "fmt" + "slices" "time" "github.com/go-acme/lego/v4/acme" @@ -13,9 +14,15 @@ import ( type OrderOptions struct { NotBefore time.Time NotAfter time.Time + + // A string uniquely identifying the profile + // which will be used to affect issuance of the certificate requested by this Order. + // - https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4 + Profile string + // A string uniquely identifying a previously-issued certificate which this // order is intended to replace. - // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5 + // - https://www.rfc-editor.org/rfc/rfc9773.html#section-5 ReplacesCertID string } @@ -28,18 +35,7 @@ func (o *OrderService) New(domains []string) (acme.ExtendedOrder, error) { // NewWithOptions Creates a new order. func (o *OrderService) NewWithOptions(domains []string, opts *OrderOptions) (acme.ExtendedOrder, error) { - var identifiers []acme.Identifier - for _, domain := range domains { - ident := acme.Identifier{Value: domain, Type: "dns"} - - if net.ParseIP(domain) != nil { - ident.Type = "ip" - } - - identifiers = append(identifiers, ident) - } - - orderReq := acme.Order{Identifiers: identifiers} + orderReq := acme.Order{Identifiers: createIdentifiers(domains)} if opts != nil { if !opts.NotAfter.IsZero() { @@ -53,12 +49,50 @@ func (o *OrderService) NewWithOptions(domains []string, opts *OrderOptions) (acm if o.core.GetDirectory().RenewalInfo != "" { orderReq.Replaces = opts.ReplacesCertID } + + if opts.Profile != "" { + orderReq.Profile = opts.Profile + } } var order acme.Order + resp, err := o.core.post(o.core.GetDirectory().NewOrderURL, orderReq, &order) if err != nil { - return acme.ExtendedOrder{}, err + are := &acme.AlreadyReplacedError{} + if !errors.As(err, &are) { + return acme.ExtendedOrder{}, err + } + + // If the Server rejects the request because the identified certificate has already been marked as replaced, + // it MUST return an HTTP 409 (Conflict) with a problem document of type "alreadyReplaced" (see Section 7.4). + // https://www.rfc-editor.org/rfc/rfc9773.html#section-5 + orderReq.Replaces = "" + + resp, err = o.core.post(o.core.GetDirectory().NewOrderURL, orderReq, &order) + if err != nil { + return acme.ExtendedOrder{}, err + } + } + + // The server MUST return an error if it cannot fulfill the request as specified, + // and it MUST NOT issue a certificate with contents other than those requested. + // If the server requires the request to be modified in a certain way, + // it should indicate the required changes using an appropriate error type and description. + // https://www.rfc-editor.org/rfc/rfc8555#section-7.4 + // + // Some ACME servers don't return an error, + // and/or change the order identifiers in the response, + // so we need to ensure that the identifiers are the same as requested. + // Deduplication by the server is allowed. + if compareIdentifiers(orderReq.Identifiers, order.Identifiers) != 0 { + // Sorts identifiers to avoid error message ambiguities about the order of the identifiers. + slices.SortStableFunc(orderReq.Identifiers, compareIdentifier) + slices.SortStableFunc(order.Identifiers, compareIdentifier) + + return acme.ExtendedOrder{}, + fmt.Errorf("order identifiers have been modified by the ACME server (RFC8555 §7.4): %+v != %+v", + orderReq.Identifiers, order.Identifiers) } return acme.ExtendedOrder{ @@ -74,6 +108,7 @@ func (o *OrderService) Get(orderURL string) (acme.ExtendedOrder, error) { } var order acme.Order + _, err := o.core.postAsGet(orderURL, &order) if err != nil { return acme.ExtendedOrder{}, err @@ -89,13 +124,14 @@ func (o *OrderService) UpdateForCSR(orderURL string, csr []byte) (acme.ExtendedO } var order acme.Order + _, err := o.core.post(orderURL, csrMsg, &order) if err != nil { return acme.ExtendedOrder{}, err } if order.Status == acme.StatusInvalid { - return acme.ExtendedOrder{}, order.Error + return acme.ExtendedOrder{}, fmt.Errorf("invalid order: %w", order.Err()) } return acme.ExtendedOrder{Order: order}, nil diff --git a/acme/api/order_test.go b/acme/api/order_test.go index 26aaa3713..f74f473d2 100644 --- a/acme/api/order_test.go +++ b/acme/api/order_test.go @@ -11,55 +11,51 @@ import ( "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestOrderService_NewWithOptions(t *testing.T) { - mux, apiURL := tester.SetupFakeAPI(t) - // small value keeps test fast - privateKey, errK := rsa.GenerateKey(rand.Reader, 512) + privateKey, errK := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, errK, "Could not generate test key") - mux.HandleFunc("/newOrder", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } + server := tester.MockACMEServer(). + Route("POST /newOrder", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + body, err := readSignedBody(req, privateKey) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } - body, err := readSignedBody(r, privateKey) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } + order := acme.Order{} - order := acme.Order{} - err = json.Unmarshal(body, &order) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } + err = json.Unmarshal(body, &order) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } - err = tester.WriteJSONResponse(w, acme.Order{ - Status: acme.StatusValid, - Expires: order.Expires, - Identifiers: order.Identifiers, - NotBefore: order.NotBefore, - NotAfter: order.NotAfter, - Error: order.Error, - Authorizations: order.Authorizations, - Finalize: order.Finalize, - Certificate: order.Certificate, - }) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + servermock.JSONEncode(acme.Order{ + Status: acme.StatusValid, + Expires: order.Expires, + Identifiers: order.Identifiers, + Profile: order.Profile, + NotBefore: order.NotBefore, + NotAfter: order.NotAfter, + Error: order.Error, + Authorizations: order.Authorizations, + Finalize: order.Finalize, + Certificate: order.Certificate, + Replaces: order.Replaces, + }).ServeHTTP(rw, req) + })). + BuildHTTPS(t) - core, err := New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey) + core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) testCases := []struct { @@ -112,6 +108,7 @@ func readSignedBody(r *http.Request, privateKey *rsa.PrivateKey) ([]byte, error) } sigAlgs := []jose.SignatureAlgorithm{jose.RS256} + jws, err := jose.ParseSigned(string(reqBody), sigAlgs) if err != nil { return nil, err diff --git a/acme/api/renewal.go b/acme/api/renewal.go index 5b4046c69..aca3d8def 100644 --- a/acme/api/renewal.go +++ b/acme/api/renewal.go @@ -14,7 +14,7 @@ var ErrNoARI = errors.New("renewalInfo[get/post]: server does not advertise a re // Note: this endpoint is part of a draft specification, not all ACME servers will implement it. // This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint. // -// https://datatracker.ietf.org/doc/draft-ietf-acme-ari +// https://www.rfc-editor.org/rfc/rfc9773.html func (c *CertificateService) GetRenewalInfo(certID string) (*http.Response, error) { if c.core.GetDirectory().RenewalInfo == "" { return nil, ErrNoARI diff --git a/acme/api/service.go b/acme/api/service.go index 6f812ee03..22ce05124 100644 --- a/acme/api/service.go +++ b/acme/api/service.go @@ -1,8 +1,11 @@ package api import ( + "fmt" "net/http" "regexp" + "strconv" + "time" ) type service struct { @@ -23,11 +26,13 @@ func getLinks(header http.Header, rel string) []string { linkExpr := regexp.MustCompile(`<(.+?)>(?:;[^;]+)*?;\s*rel="(.+?)"`) var links []string + for _, link := range header["Link"] { for _, m := range linkExpr.FindAllStringSubmatch(link, -1) { if len(m) != 3 { continue } + if m[2] == rel { links = append(links, m[1]) } @@ -54,3 +59,29 @@ func getRetryAfter(resp *http.Response) string { return resp.Header.Get("Retry-After") } + +// ParseRetryAfter parses the Retry-After header value according to RFC 7231. +// The header can be either delay-seconds (numeric) or HTTP-date (RFC 1123 format). +// https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3 +// Returns the duration until the retry time. +// TODO(ldez): unexposed this function in v5. +func ParseRetryAfter(value string) (time.Duration, error) { + if value == "" { + return 0, nil + } + + if seconds, err := strconv.ParseInt(value, 10, 64); err == nil { + return time.Duration(seconds) * time.Second, nil + } + + if retryTime, err := time.Parse(time.RFC1123, value); err == nil { + duration := time.Until(retryTime) + if duration < 0 { + return 0, nil + } + + return duration, nil + } + + return 0, fmt.Errorf("invalid Retry-After value: %q", value) +} diff --git a/acme/api/service_test.go b/acme/api/service_test.go index 2dbd795c9..57ea45708 100644 --- a/acme/api/service_test.go +++ b/acme/api/service_test.go @@ -3,8 +3,10 @@ package api import ( "net/http" "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_getLink(t *testing.T) { @@ -53,3 +55,38 @@ func Test_getLink(t *testing.T) { }) } } + +func TestParseRetryAfter(t *testing.T) { + testCases := []struct { + desc string + value string + expected time.Duration + }{ + { + desc: "empty header value", + value: "", + expected: time.Duration(0), + }, + { + desc: "delay-seconds", + value: "123", + expected: 123 * time.Second, + }, + { + desc: "HTTP-date", + value: time.Now().Add(3 * time.Second).Format(time.RFC1123), + expected: 3 * time.Second, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + rt, err := ParseRetryAfter(test.value) + require.NoError(t, err) + + assert.InDelta(t, test.expected.Seconds(), rt.Seconds(), 1) + }) + } +} diff --git a/acme/commons.go b/acme/commons.go index 39aa35ac8..0af623e4e 100644 --- a/acme/commons.go +++ b/acme/commons.go @@ -38,7 +38,7 @@ const ( // Directory the ACME directory object. // - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.1 -// - https://datatracker.ietf.org/doc/draft-ietf-acme-ari/ +// - https://www.rfc-editor.org/rfc/rfc9773.html type Directory struct { NewNonceURL string `json:"newNonce"` NewAccountURL string `json:"newAccount"` @@ -74,11 +74,17 @@ type Meta struct { // then the CA requires that all new-account requests include an "externalAccountBinding" field // associating the new account with an external account. ExternalAccountRequired bool `json:"externalAccountRequired"` + + // profiles (optional, object): + // A map of profile names to human-readable descriptions of those profiles. + // https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-3 + Profiles map[string]string `json:"profiles"` } // ExtendedAccount an extended Account. type ExtendedAccount struct { Account + // Contains the value of the response header `Location` Location string `json:"-"` } @@ -148,6 +154,12 @@ type Order struct { // An array of identifier objects that the order pertains to. Identifiers []Identifier `json:"identifiers"` + // profile (string, optional): + // A string uniquely identifying the profile + // which will be used to affect issuance of the certificate requested by this Order. + // https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4 + Profile string `json:"profile,omitempty"` + // notBefore (optional, string): // The requested value of the notBefore field in the certificate, // in the date format defined in [RFC3339]. @@ -185,10 +197,18 @@ type Order struct { // replaces (optional, string): // replaces (string, optional): A string uniquely identifying a // previously-issued certificate which this order is intended to replace. - // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5 + // - https://www.rfc-editor.org/rfc/rfc9773.html#section-5 Replaces string `json:"replaces,omitempty"` } +func (r *Order) Err() error { + if r.Error != nil { + return r.Error + } + + return nil +} + // Authorization the ACME authorization object. // - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.4 type Authorization struct { @@ -201,11 +221,11 @@ type Authorization struct { // The timestamp after which the server will consider this authorization invalid, // encoded in the format specified in RFC 3339 [RFC3339]. // This field is REQUIRED for objects with "valid" in the "status" field. - Expires time.Time `json:"expires,omitempty"` + Expires time.Time `json:"expires,omitzero"` // identifier (required, object): // The identifier that the account is authorized to represent - Identifier Identifier `json:"identifier,omitempty"` + Identifier Identifier `json:"identifier"` // challenges (required, array of objects): // For pending authorizations, the challenges that the client can fulfill in order to prove possession of the identifier. @@ -225,6 +245,7 @@ type Authorization struct { // ExtendedChallenge a extended Challenge. type ExtendedChallenge struct { Challenge + // Contains the value of the response header `Retry-After` RetryAfter string `json:"-"` // Contains the value of the response header `Link` rel="up" @@ -251,7 +272,7 @@ type Challenge struct { // The time at which the server validated this challenge, // encoded in the format specified in RFC 3339 [RFC3339]. // This field is REQUIRED if the "status" field is "valid". - Validated time.Time `json:"validated,omitempty"` + Validated time.Time `json:"validated,omitzero"` // error (optional, object): // Error that occurred while the server was validating the challenge, if any, @@ -274,6 +295,14 @@ type Challenge struct { KeyAuthorization string `json:"keyAuthorization"` } +func (c *Challenge) Err() error { + if c.Error != nil { + return c.Error + } + + return nil +} + // Identifier the ACME identifier object. // - https://www.rfc-editor.org/rfc/rfc8555.html#section-9.7.7 type Identifier struct { @@ -322,7 +351,7 @@ type Window struct { } // RenewalInfoResponse is the response to GET requests made the renewalInfo endpoint. -// - (4.1. Getting Renewal Information) https://datatracker.ietf.org/doc/draft-ietf-acme-ari/ +// - (4.1. Getting Renewal Information) https://www.rfc-editor.org/rfc/rfc9773.html type RenewalInfoResponse struct { // SuggestedWindow contains two fields, start and end, // whose values are timestamps which bound the window of time in which the CA recommends renewing the certificate. @@ -335,11 +364,11 @@ type RenewalInfoResponse struct { } // RenewalInfoUpdateRequest is the JWS payload for POST requests made to the renewalInfo endpoint. -// - (4.2. RenewalInfo Objects) https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-4.2 +// - (4.2. RenewalInfo Objects) https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2 type RenewalInfoUpdateRequest struct { // CertID is a composite string in the format: base64url(AKI) || '.' || base64url(Serial), where AKI is the // certificate's authority key identifier and Serial is the certificate's serial number. For details, see: - // https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-4.1 + // https://www.rfc-editor.org/rfc/rfc9773.html#section-4.1 CertID string `json:"certID"` // Replaced is required and indicates whether or not the client considers the certificate to have been replaced. // A certificate is considered replaced when its revocation would not disrupt any ongoing services, diff --git a/acme/errors.go b/acme/errors.go index acaea5f65..cd447d7b4 100644 --- a/acme/errors.go +++ b/acme/errors.go @@ -2,12 +2,15 @@ package acme import ( "fmt" + "strings" ) // Errors types. const ( - errNS = "urn:ietf:params:acme:error:" - BadNonceErr = errNS + "badNonce" + errNS = "urn:ietf:params:acme:error:" + BadNonceErr = errNS + "badNonce" + AlreadyReplacedErr = errNS + "alreadyReplaced" + RateLimitedErr = errNS + "rateLimited" ) // ProblemDetails the problem details object. @@ -25,30 +28,34 @@ type ProblemDetails struct { URL string `json:"url,omitempty"` } +func (p *ProblemDetails) Error() string { + msg := new(strings.Builder) + + _, _ = fmt.Fprintf(msg, "acme: error: %d", p.HTTPStatus) + + if p.Method != "" || p.URL != "" { + _, _ = fmt.Fprintf(msg, " :: %s :: %s", p.Method, p.URL) + } + + _, _ = fmt.Fprintf(msg, " :: %s :: %s", p.Type, p.Detail) + + for _, sub := range p.SubProblems { + _, _ = fmt.Fprintf(msg, ", problem: %q :: %s", sub.Type, sub.Detail) + } + + if p.Instance != "" { + msg.WriteString(", url: " + p.Instance) + } + + return msg.String() +} + // SubProblem a "subproblems". // - https://www.rfc-editor.org/rfc/rfc8555.html#section-6.7.1 type SubProblem struct { Type string `json:"type,omitempty"` Detail string `json:"detail,omitempty"` - Identifier Identifier `json:"identifier,omitempty"` -} - -func (p ProblemDetails) Error() string { - msg := fmt.Sprintf("acme: error: %d", p.HTTPStatus) - if p.Method != "" || p.URL != "" { - msg += fmt.Sprintf(" :: %s :: %s", p.Method, p.URL) - } - msg += fmt.Sprintf(" :: %s :: %s", p.Type, p.Detail) - - for _, sub := range p.SubProblems { - msg += fmt.Sprintf(", problem: %q :: %s", sub.Type, sub.Detail) - } - - if p.Instance != "" { - msg += ", url: " + p.Instance - } - - return msg + Identifier Identifier `json:"identifier"` } // NonceError represents the error which is returned @@ -56,3 +63,31 @@ func (p ProblemDetails) Error() string { type NonceError struct { *ProblemDetails } + +func (e *NonceError) Unwrap() error { + return e.ProblemDetails +} + +// AlreadyReplacedError represents the error which is returned +// if the Server rejects the request because the identified certificate has already been marked as replaced. +// - https://www.rfc-editor.org/rfc/rfc9773.html#section-5 +type AlreadyReplacedError struct { + *ProblemDetails +} + +func (e *AlreadyReplacedError) Unwrap() error { + return e.ProblemDetails +} + +// RateLimitedError represents the error which is returned +// if the server rejects the request because the client has exceeded the rate limit. +// - https://www.rfc-editor.org/rfc/rfc8555.html#section-6.6 +type RateLimitedError struct { + *ProblemDetails + + RetryAfter string +} + +func (e *RateLimitedError) Unwrap() error { + return e.ProblemDetails +} diff --git a/buildx.Dockerfile b/buildx.Dockerfile index 92a86dd3d..37f1dde94 100644 --- a/buildx.Dockerfile +++ b/buildx.Dockerfile @@ -1,10 +1,12 @@ # syntax=docker/dockerfile:1.4 FROM alpine:3 +ARG TARGETPLATFORM + RUN apk --no-cache --no-progress add git ca-certificates tzdata \ && rm -rf /var/cache/apk/* -COPY lego / +COPY $TARGETPLATFORM/lego / ENTRYPOINT ["/lego"] EXPOSE 80 diff --git a/certcrypto/crypto.go b/certcrypto/crypto.go index 43fa774ae..800bb3f5b 100644 --- a/certcrypto/crypto.go +++ b/certcrypto/crypto.go @@ -57,8 +57,10 @@ type DERCertificateBytes []byte // ParsePEMBundle parses a certificate bundle from top to bottom and returns // a slice of x509 certificates. This function will error if no certificates are found. func ParsePEMBundle(bundle []byte) ([]*x509.Certificate, error) { - var certificates []*x509.Certificate - var certDERBlock *pem.Block + var ( + certificates []*x509.Certificate + certDERBlock *pem.Block + ) for { certDERBlock, bundle = pem.Decode(bundle) @@ -71,6 +73,7 @@ func ParsePEMBundle(bundle []byte) ([]*x509.Certificate, error) { if err != nil { return nil, err } + certificates = append(certificates, cert) } } @@ -135,10 +138,29 @@ func GeneratePrivateKey(keyType KeyType) (crypto.PrivateKey, error) { return nil, fmt.Errorf("invalid KeyType: %s", keyType) } +// Deprecated: uses [CreateCSR] instead. func GenerateCSR(privateKey crypto.PrivateKey, domain string, san []string, mustStaple bool) ([]byte, error) { - var dnsNames []string - var ipAddresses []net.IP - for _, altname := range san { + return CreateCSR(privateKey, CSROptions{ + Domain: domain, + SAN: san, + MustStaple: mustStaple, + }) +} + +type CSROptions struct { + Domain string + SAN []string + MustStaple bool + EmailAddresses []string +} + +func CreateCSR(privateKey crypto.PrivateKey, opts CSROptions) ([]byte, error) { + var ( + dnsNames []string + ipAddresses []net.IP + ) + + for _, altname := range opts.SAN { if ip := net.ParseIP(altname); ip != nil { ipAddresses = append(ipAddresses, ip) } else { @@ -147,12 +169,13 @@ func GenerateCSR(privateKey crypto.PrivateKey, domain string, san []string, must } template := x509.CertificateRequest{ - Subject: pkix.Name{CommonName: domain}, - DNSNames: dnsNames, - IPAddresses: ipAddresses, + Subject: pkix.Name{CommonName: opts.Domain}, + DNSNames: dnsNames, + EmailAddresses: opts.EmailAddresses, + IPAddresses: ipAddresses, } - if mustStaple { + if opts.MustStaple { template.ExtraExtensions = append(template.ExtraExtensions, pkix.Extension{ Id: tlsFeatureExtensionOID, Value: ocspMustStapleFeature, @@ -162,12 +185,13 @@ func GenerateCSR(privateKey crypto.PrivateKey, domain string, san []string, must return x509.CreateCertificateRequest(rand.Reader, &template, privateKey) } -func PEMEncode(data interface{}) []byte { +func PEMEncode(data any) []byte { return pem.EncodeToMemory(PEMBlock(data)) } -func PEMBlock(data interface{}) *pem.Block { +func PEMBlock(data any) *pem.Block { var pemBlock *pem.Block + switch key := data.(type) { case *ecdsa.PrivateKey: keyBytes, _ := x509.MarshalECPrivateKey(key) @@ -218,15 +242,15 @@ func ParsePEMCertificate(cert []byte) (*x509.Certificate, error) { } func GetCertificateMainDomain(cert *x509.Certificate) (string, error) { - return getMainDomain(cert.Subject, cert.DNSNames) + return getMainDomain(cert.Subject, cert.DNSNames, cert.IPAddresses) } func GetCSRMainDomain(cert *x509.CertificateRequest) (string, error) { - return getMainDomain(cert.Subject, cert.DNSNames) + return getMainDomain(cert.Subject, cert.DNSNames, cert.IPAddresses) } -func getMainDomain(subject pkix.Name, dnsNames []string) (string, error) { - if subject.CommonName == "" && len(dnsNames) == 0 { +func getMainDomain(subject pkix.Name, dnsNames []string, ips []net.IP) (string, error) { + if subject.CommonName == "" && len(dnsNames) == 0 && len(ips) == 0 { return "", errors.New("missing domain") } @@ -234,7 +258,11 @@ func getMainDomain(subject pkix.Name, dnsNames []string) (string, error) { return subject.CommonName, nil } - return dnsNames[0], nil + if len(dnsNames) > 0 { + return dnsNames[0], nil + } + + return ips[0].String(), nil } func ExtractDomains(cert *x509.Certificate) []string { @@ -248,6 +276,7 @@ func ExtractDomains(cert *x509.Certificate) []string { if sanDomain == cert.Subject.CommonName { continue } + domains = append(domains, sanDomain) } @@ -299,6 +328,7 @@ func GeneratePemCert(privateKey *rsa.PrivateKey, domain string, extensions []pki func generateDerCert(privateKey *rsa.PrivateKey, expiration time.Time, domain string, extensions []pkix.Extension) ([]byte, error) { serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) if err != nil { return nil, err diff --git a/certcrypto/crypto_test.go b/certcrypto/crypto_test.go index 7aba8b378..f5609fdf4 100644 --- a/certcrypto/crypto_test.go +++ b/certcrypto/crypto_test.go @@ -6,7 +6,6 @@ import ( "crypto/rand" "crypto/rsa" "encoding/pem" - "regexp" "testing" "time" @@ -14,6 +13,13 @@ import ( "github.com/stretchr/testify/require" ) +const ( + testDomain1 = "lego.example" + testDomain2 = "a.lego.example" + testDomain3 = "b.lego.example" + testDomain4 = "c.lego.example" +) + func TestGeneratePrivateKey(t *testing.T) { key, err := GeneratePrivateKey(RSA2048) require.NoError(t, err, "Error generating private key") @@ -22,7 +28,7 @@ func TestGeneratePrivateKey(t *testing.T) { } func TestGenerateCSR(t *testing.T) { - privateKey, err := rsa.GenerateKey(rand.Reader, 512) + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Error generating private key") type expected struct { @@ -33,55 +39,75 @@ func TestGenerateCSR(t *testing.T) { testCases := []struct { desc string privateKey crypto.PrivateKey - domain string - san []string - mustStaple bool + opts CSROptions expected expected }{ { desc: "without SAN (nil)", privateKey: privateKey, - domain: "lego.acme", - mustStaple: true, - expected: expected{len: 245}, + opts: CSROptions{ + Domain: testDomain1, + MustStaple: true, + }, + expected: expected{len: 382}, }, { desc: "without SAN (empty)", privateKey: privateKey, - domain: "lego.acme", - san: []string{}, - mustStaple: true, - expected: expected{len: 245}, + opts: CSROptions{ + Domain: testDomain1, + SAN: []string{}, + MustStaple: true, + }, + expected: expected{len: 382}, }, { desc: "with SAN", privateKey: privateKey, - domain: "lego.acme", - san: []string{"a.lego.acme", "b.lego.acme", "c.lego.acme"}, - mustStaple: true, - expected: expected{len: 296}, + opts: CSROptions{ + Domain: testDomain1, + SAN: []string{testDomain2, testDomain3, testDomain4}, + MustStaple: true, + }, + expected: expected{len: 442}, }, { desc: "no domain", privateKey: privateKey, - domain: "", - mustStaple: true, - expected: expected{len: 225}, + opts: CSROptions{ + Domain: "", + MustStaple: true, + }, + expected: expected{len: 359}, }, { desc: "no domain with SAN", privateKey: privateKey, - domain: "", - san: []string{"a.lego.acme", "b.lego.acme", "c.lego.acme"}, - mustStaple: true, - expected: expected{len: 276}, + opts: CSROptions{ + Domain: "", + SAN: []string{testDomain2, testDomain3, testDomain4}, + MustStaple: true, + }, + expected: expected{len: 419}, }, { desc: "private key nil", privateKey: nil, - domain: "fizz.buzz", - mustStaple: true, - expected: expected{error: true}, + opts: CSROptions{ + Domain: testDomain1, + MustStaple: true, + }, + expected: expected{error: true}, + }, + { + desc: "with email addresses", + privateKey: privateKey, + opts: CSROptions{ + Domain: "example.com", + SAN: []string{"example.org"}, + EmailAddresses: []string{"foo@example.com", "bar@example.com"}, + }, + expected: expected{len: 421}, }, } @@ -89,7 +115,7 @@ func TestGenerateCSR(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - csr, err := GenerateCSR(test.privateKey, test.domain, test.san, test.mustStaple) + csr, err := CreateCSR(test.privateKey, test.opts) if test.expected.error { require.Error(t, err) @@ -104,17 +130,17 @@ func TestGenerateCSR(t *testing.T) { } func TestPEMEncode(t *testing.T) { - buf := bytes.NewBufferString("TestingRSAIsSoMuchFun") - - reader := MockRandReader{b: buf} - key, err := rsa.GenerateKey(reader, 32) + key, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Error generating private key") data := PEMEncode(key) require.NotNil(t, data) - exp := regexp.MustCompile(`^-----BEGIN RSA PRIVATE KEY-----\s+\S{60,}\s+-----END RSA PRIVATE KEY-----\s+`) - assert.Regexp(t, exp, string(data)) + p, rest := pem.Decode(data) + + assert.Equal(t, "RSA PRIVATE KEY", p.Type) + assert.Empty(t, rest) + assert.Empty(t, p.Headers) } func TestParsePEMCertificate(t *testing.T) { @@ -149,10 +175,13 @@ func TestParsePEMPrivateKey(t *testing.T) { pemPrivateKey := PEMEncode(privateKey) - // Decoding a key should work and create an identical key to the original + // Decoding a key should work and create an identical RSA key to the original, + // ignoring precomputed values. decoded, err := ParsePEMPrivateKey(pemPrivateKey) require.NoError(t, err) - assert.Equal(t, decoded, privateKey) + + decodedRsaPrivateKey := decoded.(*rsa.PrivateKey) + require.True(t, decodedRsaPrivateKey.Equal(privateKey)) // Decoding a PEM block that doesn't contain a private key should error _, err = ParsePEMPrivateKey(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE"})) @@ -166,11 +195,3 @@ func TestParsePEMPrivateKey(t *testing.T) { _, err = ParsePEMPrivateKey([]byte("This is not PEM")) require.Errorf(t, err, "Expected to return an error for non-PEM input") } - -type MockRandReader struct { - b *bytes.Buffer -} - -func (r MockRandReader) Read(p []byte) (int, error) { - return r.b.Read(p) -} diff --git a/certificate/authorization.go b/certificate/authorization.go index 5118912f8..49f958776 100644 --- a/certificate/authorization.go +++ b/certificate/authorization.go @@ -29,6 +29,7 @@ func (c *Certifier) getAuthorizations(order acme.ExtendedOrder) ([]acme.Authoriz var responses []acme.Authorization failures := newObtainError() + for range len(order.Authorizations) { select { case res := <-resc: @@ -52,7 +53,7 @@ func (c *Certifier) deactivateAuthorizations(order acme.ExtendedOrder, force boo for _, authzURL := range order.Authorizations { auth, err := c.core.Authorizations.Get(authzURL) if err != nil { - log.Infof("Unable to get the authorization for: %s", authzURL) + log.Infof("Unable to get the authorization for %s: %v", authzURL, err) continue } @@ -62,6 +63,7 @@ func (c *Certifier) deactivateAuthorizations(order acme.ExtendedOrder, force boo } log.Infof("Deactivating auth: %s", authzURL) + if c.core.Authorizations.Deactivate(authzURL) != nil { log.Infof("Unable to deactivate the authorization: %s", authzURL) } diff --git a/certificate/certificates.go b/certificate/certificates.go index fc139937b..04904e794 100644 --- a/certificate/certificates.go +++ b/certificate/certificates.go @@ -65,18 +65,26 @@ type Resource struct { // If `AlwaysDeactivateAuthorizations` is true, the authorizations are also relinquished if the obtain request was successful. // See https://datatracker.ietf.org/doc/html/rfc8555#section-7.5.2. type ObtainRequest struct { - Domains []string - PrivateKey crypto.PrivateKey - MustStaple bool + Domains []string + PrivateKey crypto.PrivateKey + MustStaple bool + EmailAddresses []string + + NotBefore time.Time + NotAfter time.Time + Bundle bool + PreferredChain string + + // A string uniquely identifying the profile + // which will be used to affect issuance of the certificate requested by this Order. + // - https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4 + Profile string - NotBefore time.Time - NotAfter time.Time - Bundle bool - PreferredChain string AlwaysDeactivateAuthorizations bool + // A string uniquely identifying a previously-issued certificate which this // order is intended to replace. - // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5 + // - https://www.rfc-editor.org/rfc/rfc9773.html#section-5 ReplacesCertID string } @@ -89,14 +97,23 @@ type ObtainRequest struct { type ObtainForCSRRequest struct { CSR *x509.CertificateRequest - NotBefore time.Time - NotAfter time.Time - Bundle bool - PreferredChain string + PrivateKey crypto.PrivateKey + + NotBefore time.Time + NotAfter time.Time + Bundle bool + PreferredChain string + + // A string uniquely identifying the profile + // which will be used to affect issuance of the certificate requested by this Order. + // - https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4 + Profile string + AlwaysDeactivateAuthorizations bool + // A string uniquely identifying a previously-issued certificate which this // order is intended to replace. - // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5 + // - https://www.rfc-editor.org/rfc/rfc9773.html#section-5 ReplacesCertID string } @@ -108,6 +125,7 @@ type CertifierOptions struct { KeyType certcrypto.KeyType Timeout time.Duration OverallRequestLimit int + DisableCommonName bool } // Certifier A service to obtain/renew/revoke certificates. @@ -154,6 +172,7 @@ func (c *Certifier) Obtain(request ObtainRequest) (*Resource, error) { orderOpts := &api.OrderOptions{ NotBefore: request.NotBefore, NotAfter: request.NotAfter, + Profile: request.Profile, ReplacesCertID: request.ReplacesCertID, } @@ -179,7 +198,8 @@ func (c *Certifier) Obtain(request ObtainRequest) (*Resource, error) { log.Infof("[%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", ")) failures := newObtainError() - cert, err := c.getForOrder(domains, order, request.Bundle, request.PrivateKey, request.MustStaple, request.PreferredChain) + + cert, err := c.getForOrder(domains, order, request) if err != nil { for _, auth := range authz { failures.Add(challenge.GetTargetedDomain(auth), err) @@ -220,6 +240,7 @@ func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error) orderOpts := &api.OrderOptions{ NotBefore: request.NotBefore, NotAfter: request.NotAfter, + Profile: request.Profile, ReplacesCertID: request.ReplacesCertID, } @@ -245,7 +266,13 @@ func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error) log.Infof("[%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", ")) failures := newObtainError() - cert, err := c.getForCSR(domains, order, request.Bundle, request.CSR.Raw, nil, request.PreferredChain) + + var privateKey []byte + if request.PrivateKey != nil { + privateKey = certcrypto.PEMEncode(request.PrivateKey) + } + + cert, err := c.getForCSR(domains, order, request.Bundle, request.CSR.Raw, privateKey, request.PreferredChain) if err != nil { for _, auth := range authz { failures.Add(challenge.GetTargetedDomain(auth), err) @@ -264,9 +291,12 @@ func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error) return cert, failures.Join() } -func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bundle bool, privateKey crypto.PrivateKey, mustStaple bool, preferredChain string) (*Resource, error) { +func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, request ObtainRequest) (*Resource, error) { + privateKey := request.PrivateKey + if privateKey == nil { var err error + privateKey, err = certcrypto.GeneratePrivateKey(c.options.KeyType) if err != nil { return nil, err @@ -274,7 +304,7 @@ func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bund } commonName := "" - if len(domains[0]) <= 64 { + if len(domains[0]) <= 64 && !c.options.DisableCommonName { commonName = domains[0] } @@ -296,13 +326,19 @@ func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bund } } - // TODO: should the CSR be customizable? - csr, err := certcrypto.GenerateCSR(privateKey, commonName, san, mustStaple) + csrOptions := certcrypto.CSROptions{ + Domain: commonName, + SAN: san, + MustStaple: request.MustStaple, + EmailAddresses: request.EmailAddresses, + } + + csr, err := certcrypto.CreateCSR(privateKey, csrOptions) if err != nil { return nil, err } - return c.getForCSR(domains, order, bundle, csr, certcrypto.PEMEncode(privateKey), preferredChain) + return c.getForCSR(domains, order, request.Bundle, csr, certcrypto.PEMEncode(privateKey), request.PreferredChain) } func (c *Certifier) getForCSR(domains []string, order acme.ExtendedOrder, bundle bool, csr, privateKeyPem []byte, preferredChain string) (*Resource, error) { @@ -435,11 +471,15 @@ type RenewOptions struct { NotBefore time.Time NotAfter time.Time // If true, the []byte contains both the issuer certificate and your issued certificate as a bundle. - Bundle bool - PreferredChain string + Bundle bool + PreferredChain string + + Profile string + AlwaysDeactivateAuthorizations bool // Not supported for CSR request. - MustStaple bool + MustStaple bool + EmailAddresses []string } // Renew takes a Resource and tries to renew the certificate. @@ -452,6 +492,7 @@ type RenewOptions struct { // If bundle is true, the []byte contains both the issuer certificate and your issued certificate as a bundle. // // For private key reuse the PrivateKey property of the passed in Resource should be non-nil. +// // Deprecated: use RenewWithOptions instead. func (c *Certifier) Renew(certRes Resource, bundle, mustStaple bool, preferredChain string) (*Resource, error) { return c.RenewWithOptions(certRes, &RenewOptions{ @@ -505,6 +546,7 @@ func (c *Certifier) RenewWithOptions(certRes Resource, options *RenewOptions) (* request.NotAfter = options.NotAfter request.Bundle = options.Bundle request.PreferredChain = options.PreferredChain + request.Profile = options.Profile request.AlwaysDeactivateAuthorizations = options.AlwaysDeactivateAuthorizations } @@ -530,6 +572,8 @@ func (c *Certifier) RenewWithOptions(certRes Resource, options *RenewOptions) (* request.NotAfter = options.NotAfter request.Bundle = options.Bundle request.PreferredChain = options.PreferredChain + request.EmailAddresses = options.EmailAddresses + request.Profile = options.Profile request.AlwaysDeactivateAuthorizations = options.AlwaysDeactivateAuthorizations } @@ -668,7 +712,7 @@ func checkOrderStatus(order acme.ExtendedOrder) (bool, error) { case acme.StatusValid: return true, nil case acme.StatusInvalid: - return false, order.Error + return false, fmt.Errorf("invalid order: %w", order.Err()) default: return false, nil } @@ -681,6 +725,7 @@ func checkOrderStatus(order acme.ExtendedOrder) (bool, error) { // https://www.rfc-editor.org/rfc/rfc5280.html#section-7 func sanitizeDomain(domains []string) []string { var sanitizedDomains []string + for _, domain := range domains { sanitizedDomain, err := idna.ToASCII(domain) if err != nil { @@ -689,5 +734,6 @@ func sanitizeDomain(domains []string) []string { sanitizedDomains = append(sanitizedDomains, sanitizedDomain) } } + return sanitizedDomains } diff --git a/certificate/certificates_test.go b/certificate/certificates_test.go index bff66429d..c0e35e795 100644 --- a/certificate/certificates_test.go +++ b/certificate/certificates_test.go @@ -3,7 +3,6 @@ package certificate import ( "crypto/rand" "crypto/rsa" - "encoding/pem" "fmt" "net/http" "testing" @@ -12,6 +11,7 @@ import ( "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -175,20 +175,14 @@ Ob8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ ` func Test_checkResponse(t *testing.T) { - mux, apiURL := tester.SetupFakeAPI(t) - - mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) { - _, err := w.Write([]byte(certResponseMock)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + server := tester.MockACMEServer(). + Route("POST /certificate", servermock.RawStringResponse(certResponseMock)). + BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) @@ -196,7 +190,7 @@ func Test_checkResponse(t *testing.T) { order := acme.ExtendedOrder{ Order: acme.Order{ Status: acme.StatusValid, - Certificate: apiURL + "/certificate", + Certificate: server.URL + "/certificate", }, } certRes := &Resource{} @@ -205,7 +199,7 @@ func Test_checkResponse(t *testing.T) { require.NoError(t, err) assert.True(t, valid) assert.NotNil(t, certRes) - assert.Equal(t, "", certRes.Domain) + assert.Empty(t, certRes.Domain) assert.Contains(t, certRes.CertStableURL, "/certificate") assert.Contains(t, certRes.CertURL, "/certificate") assert.Nil(t, certRes.CSR) @@ -215,30 +209,14 @@ func Test_checkResponse(t *testing.T) { } func Test_checkResponse_issuerRelUp(t *testing.T) { - mux, apiURL := tester.SetupFakeAPI(t) - - mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Link", "<"+apiURL+`/issuer>; rel="up"`) - _, err := w.Write([]byte(certResponseMock)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - mux.HandleFunc("/issuer", func(w http.ResponseWriter, _ *http.Request) { - p, _ := pem.Decode([]byte(issuerMock)) - _, err := w.Write(p.Bytes) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + server := tester.MockACMEServer(). + Route("POST /certificate", servermock.RawStringResponse(certResponseMock)). + BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) @@ -246,7 +224,7 @@ func Test_checkResponse_issuerRelUp(t *testing.T) { order := acme.ExtendedOrder{ Order: acme.Order{ Status: acme.StatusValid, - Certificate: apiURL + "/certificate", + Certificate: server.URL + "/certificate", }, } certRes := &Resource{} @@ -255,7 +233,7 @@ func Test_checkResponse_issuerRelUp(t *testing.T) { require.NoError(t, err) assert.True(t, valid) assert.NotNil(t, certRes) - assert.Equal(t, "", certRes.Domain) + assert.Empty(t, certRes.Domain) assert.Contains(t, certRes.CertStableURL, "/certificate") assert.Contains(t, certRes.CertURL, "/certificate") assert.Nil(t, certRes.CSR) @@ -265,20 +243,14 @@ func Test_checkResponse_issuerRelUp(t *testing.T) { } func Test_checkResponse_no_bundle(t *testing.T) { - mux, apiURL := tester.SetupFakeAPI(t) - - mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) { - _, err := w.Write([]byte(certResponseMock)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + server := tester.MockACMEServer(). + Route("POST /certificate", servermock.RawStringResponse(certResponseMock)). + BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) @@ -286,7 +258,7 @@ func Test_checkResponse_no_bundle(t *testing.T) { order := acme.ExtendedOrder{ Order: acme.Order{ Status: acme.StatusValid, - Certificate: apiURL + "/certificate", + Certificate: server.URL + "/certificate", }, } certRes := &Resource{} @@ -295,7 +267,7 @@ func Test_checkResponse_no_bundle(t *testing.T) { require.NoError(t, err) assert.True(t, valid) assert.NotNil(t, certRes) - assert.Equal(t, "", certRes.Domain) + assert.Empty(t, certRes.Domain) assert.Contains(t, certRes.CertStableURL, "/certificate") assert.Contains(t, certRes.CertURL, "/certificate") assert.Nil(t, certRes.CSR) @@ -305,30 +277,21 @@ func Test_checkResponse_no_bundle(t *testing.T) { } func Test_checkResponse_alternate(t *testing.T) { - mux, apiURL := tester.SetupFakeAPI(t) + server := tester.MockACMEServer(). + Route("POST /certificate", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Header().Add("Link", + fmt.Sprintf(`;title="foo";rel="alternate"`, req.Context().Value(http.LocalAddrContextKey))) - mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Add("Link", fmt.Sprintf(`<%s/certificate/1>;title="foo";rel="alternate"`, apiURL)) - - _, err := w.Write([]byte(certResponseMock)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - mux.HandleFunc("/certificate/1", func(w http.ResponseWriter, _ *http.Request) { - _, err := w.Write([]byte(certResponseMock2)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + servermock.RawStringResponse(certResponseMock).ServeHTTP(rw, req) + })). + Route("/certificate/1", servermock.RawStringResponse(certResponseMock2)). + BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) @@ -336,7 +299,7 @@ func Test_checkResponse_alternate(t *testing.T) { order := acme.ExtendedOrder{ Order: acme.Order{ Status: acme.StatusValid, - Certificate: apiURL + "/certificate", + Certificate: server.URL + "/certificate", }, } certRes := &Resource{ @@ -358,37 +321,76 @@ func Test_checkResponse_alternate(t *testing.T) { } func Test_Get(t *testing.T) { - mux, apiURL := tester.SetupFakeAPI(t) - - mux.HandleFunc("/acme/cert/test-cert", func(w http.ResponseWriter, _ *http.Request) { - _, err := w.Write([]byte(certResponseMock)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + server := tester.MockACMEServer(). + Route("POST /acme/cert/test-cert", servermock.RawStringResponse(certResponseMock)). + BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) - certRes, err := certifier.Get(apiURL+"/acme/cert/test-cert", true) + certRes, err := certifier.Get(server.URL+"/acme/cert/test-cert", true) require.NoError(t, err) assert.NotNil(t, certRes) assert.Equal(t, "acme.wtf", certRes.Domain) - assert.Equal(t, apiURL+"/acme/cert/test-cert", certRes.CertStableURL) - assert.Equal(t, apiURL+"/acme/cert/test-cert", certRes.CertURL) + assert.Equal(t, server.URL+"/acme/cert/test-cert", certRes.CertStableURL) + assert.Equal(t, server.URL+"/acme/cert/test-cert", certRes.CertURL) assert.Nil(t, certRes.CSR) assert.Nil(t, certRes.PrivateKey) assert.Equal(t, certResponseMock, string(certRes.Certificate), "Certificate") assert.Equal(t, issuerMock, string(certRes.IssuerCertificate), "IssuerCertificate") } +func Test_checkOrderStatus(t *testing.T) { + testCases := []struct { + desc string + order acme.Order + requireErr require.ErrorAssertionFunc + expected bool + }{ + { + desc: "status valid", + order: acme.Order{Status: acme.StatusValid}, + requireErr: require.NoError, + expected: true, + }, + { + desc: "status invalid", + order: acme.Order{Status: acme.StatusInvalid}, + requireErr: require.Error, + expected: false, + }, + { + desc: "status invalid with error", + order: acme.Order{Status: acme.StatusInvalid, Error: &acme.ProblemDetails{}}, + requireErr: require.Error, + expected: false, + }, + { + desc: "unknown status", + order: acme.Order{Status: "foo"}, + requireErr: require.NoError, + expected: false, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + status, err := checkOrderStatus(acme.ExtendedOrder{Order: test.order}) + test.requireErr(t, err) + + assert.Equal(t, test.expected, status) + }) + } +} + type resolverMock struct { error error } diff --git a/certificate/renewal.go b/certificate/renewal.go index ab215923d..59d31cfb5 100644 --- a/certificate/renewal.go +++ b/certificate/renewal.go @@ -11,6 +11,7 @@ import ( "time" "github.com/go-acme/lego/v4/acme" + "github.com/go-acme/lego/v4/acme/api" ) // RenewalInfoRequest contains the necessary renewal information. @@ -25,15 +26,15 @@ type RenewalInfoResponse struct { // RetryAfter header indicating the polling interval that the ACME server recommends. // Conforming clients SHOULD query the renewalInfo URL again after the RetryAfter period has passed, // as the server may provide a different suggestedWindow. - // https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-4.2 + // https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2 RetryAfter time.Duration } // ShouldRenewAt determines the optimal renewal time based on the current time (UTC),renewal window suggest by ARI, and the client's willingness to sleep. // It returns a pointer to a time.Time value indicating when the renewal should be attempted or nil if deferred until the next normal wake time. -// This method implements the RECOMMENDED algorithm described in draft-ietf-acme-ari. +// This method implements the RECOMMENDED algorithm described in RFC 9773. // -// - (4.1-11. Getting Renewal Information) https://datatracker.ietf.org/doc/draft-ietf-acme-ari/ +// - (4.1-11. Getting Renewal Information) https://www.rfc-editor.org/rfc/rfc9773.html func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.Duration) *time.Time { // Explicitly convert all times to UTC. now = now.UTC() @@ -71,7 +72,7 @@ func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.D // Note: this endpoint is part of a draft specification, not all ACME servers will implement it. // This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint. // -// https://datatracker.ietf.org/doc/draft-ietf-acme-ari +// https://www.rfc-editor.org/rfc/rfc9773.html func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse, error) { certID, err := MakeARICertID(req.Cert) if err != nil { @@ -85,22 +86,23 @@ func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse defer resp.Body.Close() var info RenewalInfoResponse + err = json.NewDecoder(resp.Body).Decode(&info) if err != nil { return nil, err } if retry := resp.Header.Get("Retry-After"); retry != "" { - info.RetryAfter, err = time.ParseDuration(retry + "s") + info.RetryAfter, err = api.ParseRetryAfter(retry) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse Retry-After header: %w", err) } } return &info, nil } -// MakeARICertID constructs a certificate identifier as described in draft-ietf-acme-ari-03, section 4.1. +// MakeARICertID constructs a certificate identifier as described in RFC 9773, section 4.1. func MakeARICertID(leaf *x509.Certificate) (string, error) { if leaf == nil { return "", errors.New("leaf certificate is nil") diff --git a/certificate/renewal_test.go b/certificate/renewal_test.go index 9f20e374e..23209638a 100644 --- a/certificate/renewal_test.go +++ b/certificate/renewal_test.go @@ -11,6 +11,7 @@ import ( "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -42,31 +43,24 @@ func TestCertifier_GetRenewalInfo(t *testing.T) { require.NoError(t, err) // Test with a fake API. - mux, apiURL := tester.SetupFakeAPI(t) - mux.HandleFunc("/renewalInfo/"+ariLeafCertID, func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Retry-After", "21600") - w.WriteHeader(http.StatusOK) - _, wErr := w.Write([]byte(`{ + server := tester.MockACMEServer(). + Route("GET /renewalInfo/"+ariLeafCertID, + servermock.RawStringResponse(`{ "suggestedWindow": { "start": "2020-03-17T17:51:09Z", "end": "2020-03-17T18:21:09Z" }, - "explanationUrl": "https://aricapable.ca/docs/renewal-advice/" + "explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/" } - }`)) - require.NoError(t, wErr) - }) + }`). + WithHeader("Content-Type", "application/json"). + WithHeader("Retry-After", "21600")). + BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) @@ -76,10 +70,46 @@ func TestCertifier_GetRenewalInfo(t *testing.T) { require.NotNil(t, ri) assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339)) assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339)) - assert.Equal(t, "https://aricapable.ca/docs/renewal-advice/", ri.ExplanationURL) + assert.Equal(t, "https://aricapable.ca.example/docs/renewal-advice/", ri.ExplanationURL) assert.Equal(t, time.Duration(21600000000000), ri.RetryAfter) } +func TestCertifier_GetRenewalInfo_retryAfter(t *testing.T) { + leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) + require.NoError(t, err) + + server := tester.MockACMEServer(). + Route("GET /renewalInfo/"+ariLeafCertID, + servermock.RawStringResponse(`{ + "suggestedWindow": { + "start": "2020-03-17T17:51:09Z", + "end": "2020-03-17T18:21:09Z" + }, + "explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/" + } + }`). + WithHeader("Content-Type", "application/json"). + WithHeader("Retry-After", time.Now().UTC().Add(6*time.Hour).Format(time.RFC1123))). + BuildHTTPS(t) + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "Could not generate test key") + + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) + require.NoError(t, err) + + certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) + + ri, err := certifier.GetRenewalInfo(RenewalInfoRequest{leaf}) + require.NoError(t, err) + require.NotNil(t, ri) + assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339)) + assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339)) + assert.Equal(t, "https://aricapable.ca.example/docs/renewal-advice/", ri.ExplanationURL) + + assert.InDelta(t, 6, ri.RetryAfter.Hours(), 0.001) +} + func TestCertifier_GetRenewalInfo_errors(t *testing.T) { leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) require.NoError(t, err) @@ -88,24 +118,23 @@ func TestCertifier_GetRenewalInfo_errors(t *testing.T) { require.NoError(t, err, "Could not generate test key") testCases := []struct { - desc string - httpClient *http.Client - request RenewalInfoRequest - handler http.HandlerFunc + desc string + timeout time.Duration + request RenewalInfoRequest + handler http.HandlerFunc }{ { - desc: "API timeout", - httpClient: &http.Client{Timeout: 500 * time.Millisecond}, // HTTP client that times out after 500ms. - request: RenewalInfoRequest{leaf}, + desc: "API timeout", + timeout: 500 * time.Millisecond, // HTTP client that times out after 500ms. + request: RenewalInfoRequest{leaf}, handler: func(w http.ResponseWriter, r *http.Request) { // API that takes 2ms to respond. time.Sleep(2 * time.Millisecond) }, }, { - desc: "API error", - httpClient: http.DefaultClient, - request: RenewalInfoRequest{leaf}, + desc: "API error", + request: RenewalInfoRequest{leaf}, handler: func(w http.ResponseWriter, r *http.Request) { // API that responds with error instead of renewal info. http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) @@ -117,10 +146,17 @@ func TestCertifier_GetRenewalInfo_errors(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - mux, apiURL := tester.SetupFakeAPI(t) - mux.HandleFunc("/renewalInfo/"+ariLeafCertID, test.handler) + server := tester.MockACMEServer(). + Route("GET /renewalInfo/"+ariLeafCertID, test.handler). + BuildHTTPS(t) - core, err := api.New(test.httpClient, "lego-test", apiURL+"/dir", "", key) + client := server.Client() + + if test.timeout != 0 { + client.Timeout = test.timeout + } + + core, err := api.New(client, "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) diff --git a/challenge/challenges.go b/challenge/challenges.go index 39bf3bee2..f6d5cdb28 100644 --- a/challenge/challenges.go +++ b/challenge/challenges.go @@ -40,5 +40,6 @@ func GetTargetedDomain(authz acme.Authorization) string { if authz.Wildcard { return "*." + authz.Identifier.Value } + return authz.Identifier.Value } diff --git a/challenge/dns01/dns_challenge.go b/challenge/dns01/dns_challenge.go index 8594d2799..1d106d7b7 100644 --- a/challenge/dns01/dns_challenge.go +++ b/challenge/dns01/dns_challenge.go @@ -40,6 +40,7 @@ func CondOption(condition bool, opt ChallengeOption) ChallengeOption { return nil } } + return opt } @@ -118,6 +119,7 @@ func (c *Challenge) Solve(authz acme.Authorization) error { info := GetChallengeInfo(authz.Identifier.Value, keyAuth) var timeout, interval time.Duration + switch provider := c.provider.(type) { case challenge.ProviderTimeout: timeout, interval = provider.Timeout() @@ -134,6 +136,7 @@ func (c *Challenge) Solve(authz acme.Authorization) error { if !stop || errP != nil { log.Infof("[%s] acme: Waiting for DNS record propagation.", domain) } + return stop, errP }) if err != nil { @@ -141,6 +144,7 @@ func (c *Challenge) Solve(authz acme.Authorization) error { } chlng.KeyAuthorization = keyAuth + return c.validate(c.core, domain, chlng) } @@ -165,6 +169,7 @@ func (c *Challenge) Sequential() (bool, time.Duration) { if p, ok := c.provider.(sequential); ok { return ok, p.Sequential() } + return false, 0 } @@ -173,6 +178,7 @@ type sequential interface { } // GetRecord returns a DNS record which will fulfill the `dns-01` challenge. +// // Deprecated: use GetChallengeInfo instead. func GetRecord(domain, keyAuth string) (fqdn, value string) { info := GetChallengeInfo(domain, keyAuth) diff --git a/challenge/dns01/dns_challenge_manual.go b/challenge/dns01/dns_challenge_manual.go index c00d64041..3821fc157 100644 --- a/challenge/dns01/dns_challenge_manual.go +++ b/challenge/dns01/dns_challenge_manual.go @@ -12,9 +12,14 @@ const ( ) // DNSProviderManual is an implementation of the ChallengeProvider interface. +// TODO(ldez): move this to providers/dns/manual +// +// Deprecated: Use the manual.DNSProvider instead. type DNSProviderManual struct{} // NewDNSProviderManual returns a DNSProviderManual instance. +// +// Deprecated: Use the manual.NewDNSProvider instead. func NewDNSProviderManual() (*DNSProviderManual, error) { return &DNSProviderManual{}, nil } diff --git a/challenge/dns01/dns_challenge_test.go b/challenge/dns01/dns_challenge_test.go index 953180326..325f1656c 100644 --- a/challenge/dns01/dns_challenge_test.go +++ b/challenge/dns01/dns_challenge_test.go @@ -4,7 +4,6 @@ import ( "crypto/rand" "crypto/rsa" "errors" - "net/http" "testing" "time" @@ -12,6 +11,8 @@ import ( "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/dnsmock" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -32,12 +33,12 @@ func (p *providerTimeoutMock) CleanUp(domain, token, keyAuth string) error { ret func (p *providerTimeoutMock) Timeout() (time.Duration, time.Duration) { return p.timeout, p.interval } func TestChallenge_PreSolve(t *testing.T) { - _, apiURL := tester.SetupFakeAPI(t) + server := tester.MockACMEServer().BuildHTTPS(t) - privateKey, err := rsa.GenerateKey(rand.Reader, 512) + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err) - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) testCases := []struct { @@ -114,12 +115,16 @@ func TestChallenge_PreSolve(t *testing.T) { } func TestChallenge_Solve(t *testing.T) { - _, apiURL := tester.SetupFakeAPI(t) + useAsNameserver(t, dnsmock.NewServer(). + Query("_acme-challenge.example.com. CNAME", dnsmock.Noop). + Build(t)) - privateKey, err := rsa.GenerateKey(rand.Reader, 512) + server := tester.MockACMEServer().BuildHTTPS(t) + + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err) - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) testCases := []struct { @@ -179,6 +184,7 @@ func TestChallenge_Solve(t *testing.T) { if test.preCheck != nil { options = append(options, WrapPreCheck(test.preCheck)) } + chlg := NewChallenge(core, test.validate, test.provider, options...) authz := acme.Authorization{ @@ -201,12 +207,12 @@ func TestChallenge_Solve(t *testing.T) { } func TestChallenge_CleanUp(t *testing.T) { - _, apiURL := tester.SetupFakeAPI(t) + server := tester.MockACMEServer().BuildHTTPS(t) - privateKey, err := rsa.GenerateKey(rand.Reader, 512) + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err) - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) testCases := []struct { @@ -281,3 +287,55 @@ func TestChallenge_CleanUp(t *testing.T) { }) } } + +func TestGetChallengeInfo(t *testing.T) { + useAsNameserver(t, dnsmock.NewServer(). + Query("_acme-challenge.example.com. CNAME", dnsmock.Noop). + Build(t)) + + info := GetChallengeInfo("example.com", "123") + + expected := ChallengeInfo{ + FQDN: "_acme-challenge.example.com.", + EffectiveFQDN: "_acme-challenge.example.com.", + Value: "pmWkWSBCL51Bfkhn79xPuKBKHz__H6B-mY6G9_eieuM", + } + + assert.Equal(t, expected, info) +} + +func TestGetChallengeInfo_CNAME(t *testing.T) { + useAsNameserver(t, dnsmock.NewServer(). + Query("_acme-challenge.example.com. CNAME", dnsmock.CNAME("example.org.")). + Query("example.org. CNAME", dnsmock.Noop). + Build(t)) + + info := GetChallengeInfo("example.com", "123") + + expected := ChallengeInfo{ + FQDN: "_acme-challenge.example.com.", + EffectiveFQDN: "example.org.", + Value: "pmWkWSBCL51Bfkhn79xPuKBKHz__H6B-mY6G9_eieuM", + } + + assert.Equal(t, expected, info) +} + +func TestGetChallengeInfo_CNAME_disabled(t *testing.T) { + useAsNameserver(t, dnsmock.NewServer(). + // Never called when the env var works. + Query("_acme-challenge.example.com. CNAME", dnsmock.CNAME("example.org.")). + Build(t)) + + t.Setenv("LEGO_DISABLE_CNAME_SUPPORT", "true") + + info := GetChallengeInfo("example.com", "123") + + expected := ChallengeInfo{ + FQDN: "_acme-challenge.example.com.", + EffectiveFQDN: "_acme-challenge.example.com.", + Value: "pmWkWSBCL51Bfkhn79xPuKBKHz__H6B-mY6G9_eieuM", + } + + assert.Equal(t, expected, info) +} diff --git a/challenge/dns01/fixtures/resolv.conf.1 b/challenge/dns01/fixtures/resolv.conf.1 index 3098f99b5..bc2a3c1ac 100644 --- a/challenge/dns01/fixtures/resolv.conf.1 +++ b/challenge/dns01/fixtures/resolv.conf.1 @@ -1,4 +1,4 @@ -domain company.com +domain example.com nameserver 10.200.3.249 nameserver 10.200.3.250:5353 nameserver 2001:4860:4860::8844 diff --git a/challenge/dns01/fqdn.go b/challenge/dns01/fqdn.go index c238c8cf5..11ac3d0c2 100644 --- a/challenge/dns01/fqdn.go +++ b/challenge/dns01/fqdn.go @@ -1,12 +1,16 @@ package dns01 +import ( + "iter" + + "github.com/miekg/dns" +) + // ToFqdn converts the name into a fqdn appending a trailing dot. +// +// Deprecated: Use [github.com/miekg/dns.Fqdn] directly. func ToFqdn(name string) string { - n := len(name) - if n == 0 || name[n-1] == '.' { - return name - } - return name + "." + return dns.Fqdn(name) } // UnFqdn converts the fqdn into a name removing the trailing dot. @@ -15,5 +19,36 @@ func UnFqdn(name string) string { if n != 0 && name[n-1] == '.' { return name[:n-1] } + return name } + +// UnFqdnDomainsSeq generates a sequence of "unFQDNed" domain names derived from a domain (FQDN or not) in descending order. +func UnFqdnDomainsSeq(fqdn string) iter.Seq[string] { + return func(yield func(string) bool) { + if fqdn == "" { + return + } + + for _, index := range dns.Split(fqdn) { + if !yield(UnFqdn(fqdn[index:])) { + return + } + } + } +} + +// DomainsSeq generates a sequence of domain names derived from a domain (FQDN or not) in descending order. +func DomainsSeq(fqdn string) iter.Seq[string] { + return func(yield func(string) bool) { + if fqdn == "" { + return + } + + for _, index := range dns.Split(fqdn) { + if !yield(fqdn[index:]) { + return + } + } + } +} diff --git a/challenge/dns01/fqdn_test.go b/challenge/dns01/fqdn_test.go index a902667a2..641e39081 100644 --- a/challenge/dns01/fqdn_test.go +++ b/challenge/dns01/fqdn_test.go @@ -1,39 +1,12 @@ package dns01 import ( + "slices" "testing" "github.com/stretchr/testify/assert" ) -func TestToFqdn(t *testing.T) { - testCases := []struct { - desc string - domain string - expected string - }{ - { - desc: "simple", - domain: "foo.example.com", - expected: "foo.example.com.", - }, - { - desc: "already FQDN", - domain: "foo.example.com.", - expected: "foo.example.com.", - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - - fqdn := ToFqdn(test.domain) - assert.Equal(t, test.expected, fqdn) - }) - } -} - func TestUnFqdn(t *testing.T) { testCases := []struct { desc string @@ -62,3 +35,103 @@ func TestUnFqdn(t *testing.T) { }) } } + +func TestUnFqdnDomainsSeq(t *testing.T) { + testCases := []struct { + desc string + fqdn string + expected []string + }{ + { + desc: "empty", + fqdn: "", + expected: nil, + }, + { + desc: "TLD", + fqdn: "com", + expected: []string{"com"}, + }, + { + desc: "2 levels", + fqdn: "example.com", + expected: []string{"example.com", "com"}, + }, + { + desc: "3 levels", + fqdn: "foo.example.com", + expected: []string{"foo.example.com", "example.com", "com"}, + }, + } + + for _, test := range testCases { + for name, suffix := range map[string]string{"": "", " FQDN": "."} { //nolint:gocritic + t.Run(test.desc+name, func(t *testing.T) { + t.Parallel() + + actual := slices.Collect(UnFqdnDomainsSeq(test.fqdn + suffix)) + + assert.Equal(t, test.expected, actual) + }) + } + } +} + +func TestDomainsSeq(t *testing.T) { + testCases := []struct { + desc string + fqdn string + expected []string + }{ + { + desc: "empty", + fqdn: "", + expected: nil, + }, + { + desc: "empty FQDN", + fqdn: ".", + expected: nil, + }, + { + desc: "TLD FQDN", + fqdn: "com", + expected: []string{"com"}, + }, + { + desc: "TLD", + fqdn: "com.", + expected: []string{"com."}, + }, + { + desc: "2 levels", + fqdn: "example.com", + expected: []string{"example.com", "com"}, + }, + { + desc: "2 levels FQDN", + fqdn: "example.com.", + expected: []string{"example.com.", "com."}, + }, + { + desc: "3 levels", + fqdn: "foo.example.com", + expected: []string{"foo.example.com", "example.com", "com"}, + }, + { + desc: "3 levels FQDN", + fqdn: "foo.example.com.", + expected: []string{"foo.example.com.", "example.com.", "com."}, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + actual := slices.Collect(DomainsSeq(test.fqdn)) + + assert.Equal(t, test.expected, actual) + }) + } +} diff --git a/challenge/dns01/mock_test.go b/challenge/dns01/mock_test.go new file mode 100644 index 000000000..5dcad3013 --- /dev/null +++ b/challenge/dns01/mock_test.go @@ -0,0 +1,81 @@ +package dns01 + +import ( + "context" + "net" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +func fakeNS(name, ns string) *dns.NS { + return &dns.NS{ + Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 172800}, + Ns: ns, + } +} + +func fakeA(name, ip string) *dns.A { + return &dns.A{ + Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 10}, + A: net.ParseIP(ip), + } +} + +func fakeTXT(name, value string) *dns.TXT { + return &dns.TXT{ + Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 10}, + Txt: []string{value}, + } +} + +// mockResolver modifies the default DNS resolver to use a custom network address during the test execution. +// IMPORTANT: it modifying global variables. +func mockResolver(t *testing.T, addr net.Addr) { + t.Helper() + + _, port, err := net.SplitHostPort(addr.String()) + require.NoError(t, err) + + originalDefaultNameserverPort := defaultNameserverPort + + t.Cleanup(func() { + defaultNameserverPort = originalDefaultNameserverPort + }) + + defaultNameserverPort = port + + originalResolver := net.DefaultResolver + + t.Cleanup(func() { + net.DefaultResolver = originalResolver + }) + + net.DefaultResolver = &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: 1 * time.Second} + + return d.DialContext(ctx, network, addr.String()) + }, + } +} + +func useAsNameserver(t *testing.T, addr net.Addr) { + t.Helper() + + ClearFqdnCache() + t.Cleanup(func() { + ClearFqdnCache() + }) + + originalRecursiveNameservers := recursiveNameservers + + t.Cleanup(func() { + recursiveNameservers = originalRecursiveNameservers + }) + + recursiveNameservers = ParseNameservers([]string{addr.String()}) +} diff --git a/challenge/dns01/nameserver.go b/challenge/dns01/nameserver.go index a8d678af2..554eb7cc2 100644 --- a/challenge/dns01/nameserver.go +++ b/challenge/dns01/nameserver.go @@ -81,6 +81,7 @@ func getNameservers(path string, defaults []string) []string { func ParseNameservers(servers []string) []string { var resolvers []string + for _, resolver := range servers { // ensure all servers have a port number if _, _, err := net.SplitHostPort(resolver); err != nil { @@ -89,6 +90,7 @@ func ParseNameservers(servers []string) []string { resolvers = append(resolvers, resolver) } } + return resolvers } @@ -132,6 +134,7 @@ func FindPrimaryNsByFqdnCustom(fqdn string, nameservers []string) (string, error if err != nil { return "", fmt.Errorf("[fqdn=%s] %w", fqdn, err) } + return soa.primaryNs, nil } @@ -148,6 +151,7 @@ func FindZoneByFqdnCustom(fqdn string, nameservers []string) (string, error) { if err != nil { return "", fmt.Errorf("[fqdn=%s] %w", fqdn, err) } + return soa.zone, nil } @@ -172,13 +176,12 @@ func lookupSoaByFqdn(fqdn string, nameservers []string) (*soaCacheEntry, error) } func fetchSoaByFqdn(fqdn string, nameservers []string) (*soaCacheEntry, error) { - var err error - var r *dns.Msg - - labelIndexes := dns.Split(fqdn) - for _, index := range labelIndexes { - domain := fqdn[index:] + var ( + err error + r *dns.Msg + ) + for domain := range DomainsSeq(fqdn) { r, err = dnsQuery(domain, dns.TypeSOA, nameservers, true) if err != nil { continue @@ -232,9 +235,11 @@ func dnsQuery(fqdn string, rtype uint16, nameservers []string, recursive bool) ( return nil, &DNSError{Message: "empty list of nameservers"} } - var r *dns.Msg - var err error - var errAll error + var ( + r *dns.Msg + err error + errAll error + ) for _, ns := range nameservers { r, err = sendDNSQuery(m, ns) @@ -267,6 +272,7 @@ func createDNSMsg(fqdn string, rtype uint16, recursive bool) *dns.Msg { func sendDNSQuery(m *dns.Msg, ns string) (*dns.Msg, error) { if ok, _ := strconv.ParseBool(os.Getenv("LEGO_EXPERIMENTAL_DNS_TCP_ONLY")); ok { tcp := &dns.Client{Net: "tcp", Timeout: dnsTimeout} + r, _, err := tcp.Exchange(m, ns) if err != nil { return r, &DNSError{Message: "DNS call error", MsgIn: m, NS: ns, Err: err} diff --git a/challenge/dns01/nameserver_test.go b/challenge/dns01/nameserver_test.go index 15b19beba..dd4d66dcb 100644 --- a/challenge/dns01/nameserver_test.go +++ b/challenge/dns01/nameserver_test.go @@ -5,138 +5,237 @@ import ( "sort" "testing" + "github.com/go-acme/lego/v4/platform/tester/dnsmock" "github.com/miekg/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestLookupNameserversOK(t *testing.T) { +func Test_lookupNameserversOK(t *testing.T) { testCases := []struct { - fqdn string - nss []string + desc string + fakeDNSServer *dnsmock.Builder + fqdn string + expected []string }{ { - fqdn: "en.wikipedia.org.", - nss: []string{"ns0.wikimedia.org.", "ns1.wikimedia.org.", "ns2.wikimedia.org."}, + fqdn: "en.wikipedia.org.localhost.", + fakeDNSServer: dnsmock.NewServer(). + Query("en.wikipedia.org.localhost SOA", dnsmock.CNAME("dyna.wikimedia.org.localhost")). + Query("wikipedia.org.localhost SOA", dnsmock.SOA("")). + Query("wikipedia.org.localhost NS", + dnsmock.Answer( + fakeNS("wikipedia.org.localhost.", "ns0.wikimedia.org.localhost."), + fakeNS("wikipedia.org.localhost.", "ns1.wikimedia.org.localhost."), + fakeNS("wikipedia.org.localhost.", "ns2.wikimedia.org.localhost."), + ), + ), + expected: []string{"ns0.wikimedia.org.localhost.", "ns1.wikimedia.org.localhost.", "ns2.wikimedia.org.localhost."}, }, { - fqdn: "www.google.com.", - nss: []string{"ns1.google.com.", "ns2.google.com.", "ns3.google.com.", "ns4.google.com."}, + fqdn: "www.google.com.localhost.", + fakeDNSServer: dnsmock.NewServer(). + Query("www.google.com.localhost. SOA", dnsmock.Noop). + Query("google.com.localhost. SOA", dnsmock.SOA("")). + Query("google.com.localhost. NS", + dnsmock.Answer( + fakeNS("google.com.localhost.", "ns1.google.com.localhost."), + fakeNS("google.com.localhost.", "ns2.google.com.localhost."), + fakeNS("google.com.localhost.", "ns3.google.com.localhost."), + fakeNS("google.com.localhost.", "ns4.google.com.localhost."), + ), + ), + expected: []string{"ns1.google.com.localhost.", "ns2.google.com.localhost.", "ns3.google.com.localhost.", "ns4.google.com.localhost."}, }, { - fqdn: "physics.georgetown.edu.", - nss: []string{"ns4.georgetown.edu.", "ns5.georgetown.edu.", "ns6.georgetown.edu."}, + fqdn: "mail.proton.me.localhost.", + fakeDNSServer: dnsmock.NewServer(). + Query("mail.proton.me.localhost. SOA", dnsmock.Noop). + Query("proton.me.localhost. SOA", dnsmock.SOA("")). + Query("proton.me.localhost. NS", + dnsmock.Answer( + fakeNS("proton.me.localhost.", "ns1.proton.me.localhost."), + fakeNS("proton.me.localhost.", "ns2.proton.me.localhost."), + fakeNS("proton.me.localhost.", "ns3.proton.me.localhost."), + ), + ), + expected: []string{"ns1.proton.me.localhost.", "ns2.proton.me.localhost.", "ns3.proton.me.localhost."}, }, } for _, test := range testCases { t.Run(test.fqdn, func(t *testing.T) { - t.Parallel() + useAsNameserver(t, test.fakeDNSServer.Build(t)) nss, err := lookupNameservers(test.fqdn) require.NoError(t, err) sort.Strings(nss) - sort.Strings(test.nss) + sort.Strings(test.expected) - assert.EqualValues(t, test.nss, nss) + assert.Equal(t, test.expected, nss) }) } } -func TestLookupNameserversErr(t *testing.T) { +func Test_lookupNameserversErr(t *testing.T) { testCases := []struct { - desc string - fqdn string - error string + desc string + fqdn string + fakeDNSServer *dnsmock.Builder + error string }{ { - desc: "invalid tld", - fqdn: "_null.n0n0.", - error: "could not find zone", + desc: "NXDOMAIN", + fqdn: "example.invalid.", + fakeDNSServer: dnsmock.NewServer(). + Query(". SOA", dnsmock.Error(dns.RcodeNameError)), + error: "could not find zone: [fqdn=example.invalid.] could not find the start of authority for 'example.invalid.' [question='invalid. IN SOA', code=NXDOMAIN]", + }, + { + desc: "NS error", + fqdn: "example.com.", + fakeDNSServer: dnsmock.NewServer(). + Query("example.com. SOA", dnsmock.SOA("")). + Query("example.com. NS", dnsmock.Error(dns.RcodeServerFailure)), + error: "[zone=example.com.] could not determine authoritative nameservers", + }, + { + desc: "empty NS", + fqdn: "example.com.", + fakeDNSServer: dnsmock.NewServer(). + Query("example.com. SOA", dnsmock.SOA("")). + Query("example.me NS", dnsmock.Noop), + error: "[zone=example.com.] could not determine authoritative nameservers", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - t.Parallel() + useAsNameserver(t, test.fakeDNSServer.Build(t)) _, err := lookupNameservers(test.fqdn) require.Error(t, err) - assert.Contains(t, err.Error(), test.error) + assert.EqualError(t, err, test.error) }) } } -var findXByFqdnTestCases = []struct { +type lookupSoaByFqdnTestCase struct { desc string fqdn string zone string primaryNs string nameservers []string expectedError string -}{ - { - desc: "domain is a CNAME", - fqdn: "mail.google.com.", - zone: "google.com.", - primaryNs: "ns1.google.com.", - nameservers: recursiveNameservers, - }, - { - desc: "domain is a non-existent subdomain", - fqdn: "foo.google.com.", - zone: "google.com.", - primaryNs: "ns1.google.com.", - nameservers: recursiveNameservers, - }, - { - desc: "domain is a eTLD", - fqdn: "example.com.ac.", - zone: "ac.", - primaryNs: "a0.nic.ac.", - nameservers: recursiveNameservers, - }, - { - desc: "domain is a cross-zone CNAME", - fqdn: "cross-zone-example.assets.sh.", - zone: "assets.sh.", - primaryNs: "gina.ns.cloudflare.com.", - nameservers: recursiveNameservers, - }, - { - desc: "NXDOMAIN", - fqdn: "test.lego.zz.", - zone: "lego.zz.", - nameservers: []string{"8.8.8.8:53"}, - expectedError: "[fqdn=test.lego.zz.] could not find the start of authority for 'test.lego.zz.' [question='zz. IN SOA', code=NXDOMAIN]", - }, - { - desc: "several non existent nameservers", - fqdn: "mail.google.com.", - zone: "google.com.", - primaryNs: "ns1.google.com.", - nameservers: []string{":7053", ":8053", "8.8.8.8:53"}, - }, - { - desc: "only non-existent nameservers", - fqdn: "mail.google.com.", - zone: "google.com.", - nameservers: []string{":7053", ":8053", ":9053"}, - // use only the start of the message because the port changes with each call: 127.0.0.1:XXXXX->127.0.0.1:7053. - expectedError: "[fqdn=mail.google.com.] could not find the start of authority for 'mail.google.com.': DNS call error: read udp ", - }, - { - desc: "no nameservers", - fqdn: "test.ldez.com.", - zone: "ldez.com.", - nameservers: []string{}, - expectedError: "[fqdn=test.ldez.com.] could not find the start of authority for 'test.ldez.com.': empty list of nameservers", - }, +} + +func lookupSoaByFqdnTestCases(t *testing.T) []lookupSoaByFqdnTestCase { + t.Helper() + + return []lookupSoaByFqdnTestCase{ + { + desc: "domain is a CNAME", + fqdn: "mail.example.com.", + zone: "example.com.", + primaryNs: "ns1.example.com.", + nameservers: []string{ + dnsmock.NewServer(). + Query("mail.example.com. SOA", dnsmock.CNAME("example.com.")). + Query("example.com. SOA", dnsmock.SOA("")). + Build(t). + String(), + }, + }, + { + desc: "domain is a non-existent subdomain", + fqdn: "foo.example.com.", + zone: "example.com.", + primaryNs: "ns1.example.com.", + nameservers: []string{ + dnsmock.NewServer(). + Query("foo.example.com. SOA", dnsmock.Error(dns.RcodeNameError)). + Query("example.com. SOA", dnsmock.SOA("")). + Build(t). + String(), + }, + }, + { + desc: "domain is a eTLD", + fqdn: "example.com.ac.", + zone: "ac.", + primaryNs: "ns1.nic.ac.", + nameservers: []string{ + dnsmock.NewServer(). + Query("example.com.ac. SOA", dnsmock.Error(dns.RcodeNameError)). + Query("com.ac. SOA", dnsmock.Error(dns.RcodeNameError)). + Query("ac. SOA", dnsmock.SOA("")). + Build(t). + String(), + }, + }, + { + desc: "domain is a cross-zone CNAME", + fqdn: "cross-zone-example.example.com.", + zone: "example.com.", + primaryNs: "ns1.example.com.", + nameservers: []string{ + dnsmock.NewServer(). + Query("cross-zone-example.example.com. SOA", dnsmock.CNAME("example.org.")). + Query("example.com. SOA", dnsmock.SOA("")). + Build(t). + String(), + }, + }, + { + desc: "NXDOMAIN", + fqdn: "test.lego.invalid.", + zone: "lego.invalid.", + nameservers: []string{ + dnsmock.NewServer(). + Query("test.lego.invalid. SOA", dnsmock.Error(dns.RcodeNameError)). + Query("lego.invalid. SOA", dnsmock.Error(dns.RcodeNameError)). + Query("invalid. SOA", dnsmock.Error(dns.RcodeNameError)). + Build(t). + String(), + }, + expectedError: `[fqdn=test.lego.invalid.] could not find the start of authority for 'test.lego.invalid.' [question='invalid. IN SOA', code=NXDOMAIN]`, + }, + { + desc: "several non existent nameservers", + fqdn: "mail.example.com.", + zone: "example.com.", + primaryNs: "ns1.example.com.", + nameservers: []string{ + ":7053", + ":8053", + dnsmock.NewServer(). + Query("mail.example.com. SOA", dnsmock.CNAME("example.com.")). + Query("example.com. SOA", dnsmock.SOA("")). + Build(t). + String(), + }, + }, + { + desc: "only non-existent nameservers", + fqdn: "mail.example.com.", + zone: "example.com.", + nameservers: []string{":7053", ":8053", ":9053"}, + // use only the start of the message because the port changes with each call: 127.0.0.1:XXXXX->127.0.0.1:7053. + expectedError: "[fqdn=mail.example.com.] could not find the start of authority for 'mail.example.com.': DNS call error: read udp ", + }, + { + desc: "no nameservers", + fqdn: "test.example.com.", + zone: "example.com.", + nameservers: []string{}, + expectedError: "[fqdn=test.example.com.] could not find the start of authority for 'test.example.com.': empty list of nameservers", + }, + } } func TestFindZoneByFqdnCustom(t *testing.T) { - for _, test := range findXByFqdnTestCases { + for _, test := range lookupSoaByFqdnTestCases(t) { t.Run(test.desc, func(t *testing.T) { ClearFqdnCache() @@ -153,7 +252,7 @@ func TestFindZoneByFqdnCustom(t *testing.T) { } func TestFindPrimaryNsByFqdnCustom(t *testing.T) { - for _, test := range findXByFqdnTestCases { + for _, test := range lookupSoaByFqdnTestCases(t) { t.Run(test.desc, func(t *testing.T) { ClearFqdnCache() @@ -169,7 +268,7 @@ func TestFindPrimaryNsByFqdnCustom(t *testing.T) { } } -func TestResolveConfServers(t *testing.T) { +func Test_getNameservers_ResolveConfServers(t *testing.T) { testCases := []struct { fixture string expected []string diff --git a/challenge/dns01/precheck.go b/challenge/dns01/precheck.go index 706e8dbec..45e17e3ac 100644 --- a/challenge/dns01/precheck.go +++ b/challenge/dns01/precheck.go @@ -9,6 +9,10 @@ import ( "github.com/miekg/dns" ) +// defaultNameserverPort used by authoritative NS. +// This is for tests only. +var defaultNameserverPort = "53" + // PreCheckFunc checks DNS propagation before notifying ACME that the DNS challenge is ready. type PreCheckFunc func(fqdn, value string) (bool, error) @@ -25,6 +29,7 @@ func WrapPreCheck(wrap WrapPreCheckFunc) ChallengeOption { } // DisableCompletePropagationRequirement obsolete. +// // Deprecated: use DisableAuthoritativeNssPropagationRequirement instead. func DisableCompletePropagationRequirement() ChallengeOption { return DisableAuthoritativeNssPropagationRequirement() @@ -121,7 +126,7 @@ func (p preCheck) checkDNSPropagation(fqdn, value string) (bool, error) { func checkNameserversPropagation(fqdn, value string, nameservers []string, addPort bool) (bool, error) { for _, ns := range nameservers { if addPort { - ns = net.JoinHostPort(ns, "53") + ns = net.JoinHostPort(ns, defaultNameserverPort) } r, err := dnsQuery(fqdn, dns.TypeTXT, []string{ns}, false) @@ -136,9 +141,11 @@ func checkNameserversPropagation(fqdn, value string, nameservers []string, addPo var records []string var found bool + for _, rr := range r.Answer { if txt, ok := rr.(*dns.TXT); ok { record := strings.Join(txt.Txt, "") + records = append(records, record) if record == value { found = true diff --git a/challenge/dns01/precheck_test.go b/challenge/dns01/precheck_test.go index 1f3ecbf7e..bda8c781e 100644 --- a/challenge/dns01/precheck_test.go +++ b/challenge/dns01/precheck_test.go @@ -3,40 +3,73 @@ package dns01 import ( "testing" + "github.com/go-acme/lego/v4/platform/tester/dnsmock" + "github.com/miekg/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestCheckDNSPropagation(t *testing.T) { +func Test_preCheck_checkDNSPropagation(t *testing.T) { + mockResolver(t, + dnsmock.NewServer(). + Query("ns0.lego.localhost. A", + dnsmock.Answer(fakeA("ns0.lego.localhost.", "127.0.0.1"))). + Query("ns1.lego.localhost. A", + dnsmock.Answer(fakeA("ns1.lego.localhost.", "127.0.0.1"))). + Query("example.com. TXT", + dnsmock.Answer( + fakeTXT("example.com.", "one"), + fakeTXT("example.com.", "two"), + fakeTXT("example.com.", "three"), + fakeTXT("example.com.", "four"), + fakeTXT("example.com.", "five"), + ), + ). + Build(t), + ) + + useAsNameserver(t, + dnsmock.NewServer(). + Query("acme-staging.api.example.com. SOA", dnsmock.Error(dns.RcodeNameError)). + Query("api.example.com. SOA", dnsmock.Error(dns.RcodeNameError)). + Query("example.com. SOA", dnsmock.SOA("")). + Query("example.com. NS", + dnsmock.Answer( + fakeNS("example.com.", "ns0.lego.localhost."), + fakeNS("example.com.", "ns1.lego.localhost."), + ), + ). + Build(t), + ) + testCases := []struct { - desc string - fqdn string - value string - expectError bool + desc string + fqdn string + value string + expectedError string }{ { desc: "success", - fqdn: "postman-echo.com.", - value: "postman-domain-verification=c85de626cb79d941310696e06558e2e790223802f3697dfbdcaf65510152d52c", + fqdn: "example.com.", + value: "four", }, { - desc: "no TXT record", - fqdn: "acme-staging.api.letsencrypt.org.", - value: "fe01=", - expectError: true, + desc: "no matching TXT record", + fqdn: "acme-staging.api.example.com.", + value: "fe01=", + expectedError: "did not return the expected TXT record [fqdn: acme-staging.api.example.com., value: fe01=]: one ,two ,three ,four ,five", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - t.Parallel() ClearFqdnCache() check := newPreCheck() ok, err := check.checkDNSPropagation(test.fqdn, test.value) - if test.expectError { - assert.Errorf(t, err, "PreCheckDNS must fail for %s", test.fqdn) + if test.expectedError != "" { + assert.ErrorContainsf(t, err, test.expectedError, "PreCheckDNS must fail for %s", test.fqdn) assert.False(t, ok, "PreCheckDNS must fail for %s", test.fqdn) } else { assert.NoErrorf(t, err, "PreCheckDNS failed for %s", test.fqdn) @@ -46,69 +79,67 @@ func TestCheckDNSPropagation(t *testing.T) { } } -func TestCheckAuthoritativeNss(t *testing.T) { +func Test_checkNameserversPropagation_authoritativeNss(t *testing.T) { testCases := []struct { - desc string - fqdn, value string - ns []string - expected bool + desc string + fqdn, value string + fakeDNSServer *dnsmock.Builder + expectedError string }{ { - desc: "TXT RR w/ expected value", - fqdn: "8.8.8.8.asn.routeviews.org.", - value: "151698.8.8.024", - ns: []string{"asnums.routeviews.org."}, - expected: true, + desc: "TXT RR w/ expected value", + // NS: asnums.routeviews.org. + fqdn: "8.8.8.8.asn.routeviews.org.", + value: "151698.8.8.024", + fakeDNSServer: dnsmock.NewServer(). + Query("8.8.8.8.asn.routeviews.org. TXT", + dnsmock.Answer( + fakeTXT("8.8.8.8.asn.routeviews.org.", "151698.8.8.024"), + ), + ), + }, + { + desc: "TXT RR w/ unexpected value", + // NS: asnums.routeviews.org. + fqdn: "8.8.8.8.asn.routeviews.org.", + value: "fe01=", + fakeDNSServer: dnsmock.NewServer(). + Query("8.8.8.8.asn.routeviews.org. TXT", + dnsmock.Answer( + fakeTXT("8.8.8.8.asn.routeviews.org.", "15169"), + fakeTXT("8.8.8.8.asn.routeviews.org.", "8.8.8.0"), + fakeTXT("8.8.8.8.asn.routeviews.org.", "24"), + ), + ), + expectedError: "did not return the expected TXT record [fqdn: 8.8.8.8.asn.routeviews.org., value: fe01=]: 15169 ,8.8.8.0 ,24", }, { desc: "No TXT RR", - fqdn: "ns1.google.com.", - ns: []string{"ns2.google.com."}, - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - ClearFqdnCache() - - ok, _ := checkNameserversPropagation(test.fqdn, test.value, test.ns, true) - assert.Equal(t, test.expected, ok, test.fqdn) - }) - } -} - -func TestCheckAuthoritativeNssErr(t *testing.T) { - testCases := []struct { - desc string - fqdn, value string - ns []string - error string - }{ - { - desc: "TXT RR /w unexpected value", - fqdn: "8.8.8.8.asn.routeviews.org.", - value: "fe01=", - ns: []string{"asnums.routeviews.org."}, - error: "did not return the expected TXT record", - }, - { - desc: "No TXT RR", + // NS: ns2.google.com. fqdn: "ns1.google.com.", value: "fe01=", - ns: []string{"ns2.google.com."}, - error: "did not return the expected TXT record", + fakeDNSServer: dnsmock.NewServer(). + Query("ns1.google.com.", dnsmock.Noop), + expectedError: "did not return the expected TXT record [fqdn: ns1.google.com., value: fe01=]: ", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - t.Parallel() ClearFqdnCache() - _, err := checkNameserversPropagation(test.fqdn, test.value, test.ns, true) - require.Error(t, err) - assert.Contains(t, err.Error(), test.error) + addr := test.fakeDNSServer.Build(t) + + ok, err := checkNameserversPropagation(test.fqdn, test.value, []string{addr.String()}, false) + + if test.expectedError == "" { + require.NoError(t, err) + assert.True(t, ok) + } else { + require.Error(t, err) + require.ErrorContains(t, err, test.expectedError) + assert.False(t, ok) + } }) } } diff --git a/challenge/http01/domain_matcher.go b/challenge/http01/domain_matcher.go index 5c755c4b2..058d1a314 100644 --- a/challenge/http01/domain_matcher.go +++ b/challenge/http01/domain_matcher.go @@ -3,6 +3,7 @@ package http01 import ( "fmt" "net/http" + "net/netip" "strings" ) @@ -54,7 +55,7 @@ func (m *hostMatcher) name() string { } func (m *hostMatcher) matches(r *http.Request, domain string) bool { - return strings.HasPrefix(r.Host, domain) + return matchDomain(r.Host, domain) } // arbitraryMatcher checks whether the specified (*net/http.Request).Header value starts with a domain name. @@ -65,7 +66,7 @@ func (m arbitraryMatcher) name() string { } func (m arbitraryMatcher) matches(r *http.Request, domain string) bool { - return strings.HasPrefix(r.Header.Get(m.name()), domain) + return matchDomain(r.Header.Get(m.name()), domain) } // forwardedMatcher checks whether the Forwarded header contains a "host" element starting with a domain name. @@ -87,7 +88,8 @@ func (m *forwardedMatcher) matches(r *http.Request, domain string) bool { } host := fwds[0]["host"] - return strings.HasPrefix(host, domain) + + return matchDomain(host, domain) } // parsing requires some form of state machine. @@ -98,6 +100,7 @@ func parseForwardedHeader(s string) (elements []map[string]string, err error) { inquote := false pos := 0 + l := len(s) for i := 0; i < l; i++ { r := rune(s[i]) @@ -109,6 +112,7 @@ func parseForwardedHeader(s string) (elements []map[string]string, err error) { pos = i inquote = false } + continue } @@ -117,6 +121,7 @@ func parseForwardedHeader(s string) (elements []map[string]string, err error) { if key == "" { return nil, fmt.Errorf("unexpected quoted string as pos %d", i) } + inquote = true pos = i + 1 @@ -133,11 +138,10 @@ func parseForwardedHeader(s string) (elements []map[string]string, err error) { case r == ',': // end of forwarded-element if key != "" { - if val == "" { - val = s[pos:i] - } + val = s[pos:i] cur[key] = val } + elements = append(elements, cur) cur = make(map[string]string) key = "" @@ -160,11 +164,14 @@ func parseForwardedHeader(s string) (elements []map[string]string, err error) { if pos < len(s) { val = s[pos:] } + cur[key] = val } + if len(cur) > 0 { elements = append(elements, cur) } + return elements, nil } @@ -179,9 +186,19 @@ func skipWS(s string, i int) int { for isWS(rune(s[i+1])) { i++ } + return i } func isWS(r rune) bool { return strings.ContainsRune(" \t\v\r\n", r) } + +func matchDomain(src, domain string) bool { + addr, err := netip.ParseAddr(domain) + if err == nil && addr.Is6() { + domain = "[" + domain + "]" + } + + return strings.HasPrefix(src, domain) +} diff --git a/challenge/http01/domain_matcher_test.go b/challenge/http01/domain_matcher_test.go index 94add14bb..7bedf9f63 100644 --- a/challenge/http01/domain_matcher_test.go +++ b/challenge/http01/domain_matcher_test.go @@ -1,13 +1,15 @@ package http01 import ( + "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestParseForwardedHeader(t *testing.T) { +func Test_parseForwardedHeader(t *testing.T) { testCases := []struct { name string input string @@ -75,7 +77,7 @@ func TestParseForwardedHeader(t *testing.T) { actual, err := parseForwardedHeader(test.input) if test.err == "" { require.NoError(t, err) - assert.EqualValues(t, test.want, actual) + assert.Equal(t, test.want, actual) } else { require.Error(t, err) assert.Contains(t, err.Error(), test.err) @@ -83,3 +85,54 @@ func TestParseForwardedHeader(t *testing.T) { }) } } + +func Test_hostMatcher_matches(t *testing.T) { + hm := &hostMatcher{} + + testCases := []struct { + desc string + domain string + req *http.Request + expected assert.BoolAssertionFunc + }{ + { + desc: "exact domain", + domain: "example.com", + req: httptest.NewRequest(http.MethodGet, "http://example.com", nil), + expected: assert.True, + }, + { + desc: "request with path", + domain: "example.com", + req: httptest.NewRequest(http.MethodGet, "http://example.com/foo/bar", nil), + expected: assert.True, + }, + { + desc: "ipv4", + domain: "127.0.0.1", + req: httptest.NewRequest(http.MethodGet, "http://127.0.0.1", nil), + expected: assert.True, + }, + { + desc: "ipv6", + domain: "2001:db8::1", + req: httptest.NewRequest(http.MethodGet, "http://[2001:db8::1]", nil), + expected: assert.True, + }, + { + desc: "ipv6 with brackets", + domain: "[2001:db8::1]", + req: httptest.NewRequest(http.MethodGet, "http://[2001:db8::1]", nil), + expected: assert.True, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + hm.matches(test.req, test.domain) + + test.expected(t, hm.matches(test.req, test.domain)) + }) + } +} diff --git a/challenge/http01/http_challenge.go b/challenge/http01/http_challenge.go index f23e483cf..a042979c2 100644 --- a/challenge/http01/http_challenge.go +++ b/challenge/http01/http_challenge.go @@ -2,6 +2,7 @@ package http01 import ( "fmt" + "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" @@ -11,6 +12,16 @@ import ( type ValidateFunc func(core *api.Core, domain string, chlng acme.Challenge) error +type ChallengeOption func(*Challenge) error + +// SetDelay sets a delay between the start of the HTTP server and the challenge validation. +func SetDelay(delay time.Duration) ChallengeOption { + return func(chlg *Challenge) error { + chlg.delay = delay + return nil + } +} + // ChallengePath returns the URL path for the `http-01` challenge. func ChallengePath(token string) string { return "/.well-known/acme-challenge/" + token @@ -20,14 +31,24 @@ type Challenge struct { core *api.Core validate ValidateFunc provider challenge.Provider + delay time.Duration } -func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider) *Challenge { - return &Challenge{ +func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider, opts ...ChallengeOption) *Challenge { + chlg := &Challenge{ core: core, validate: validate, provider: provider, } + + for _, opt := range opts { + err := opt(chlg) + if err != nil { + log.Infof("challenge option error: %v", err) + } + } + + return chlg } func (c *Challenge) SetProvider(provider challenge.Provider) { @@ -53,6 +74,7 @@ func (c *Challenge) Solve(authz acme.Authorization) error { if err != nil { return fmt.Errorf("[%s] acme: error presenting token: %w", domain, err) } + defer func() { err := c.provider.CleanUp(authz.Identifier.Value, chlng.Token, keyAuth) if err != nil { @@ -60,6 +82,11 @@ func (c *Challenge) Solve(authz acme.Authorization) error { } }() + if c.delay > 0 { + time.Sleep(c.delay) + } + chlng.KeyAuthorization = keyAuth + return c.validate(c.core, domain, chlng) } diff --git a/challenge/http01/http_challenge_server.go b/challenge/http01/http_challenge_server.go index f69f5ac1f..ab962917e 100644 --- a/challenge/http01/http_challenge_server.go +++ b/challenge/http01/http_challenge_server.go @@ -44,6 +44,7 @@ func NewUnixProviderServer(socketPath string, mode fs.FileMode) *ProviderServer // Present starts a web server and makes the token available at `ChallengePath(token)` for web requests. func (s *ProviderServer) Present(domain, token, keyAuth string) error { var err error + s.listener, err = net.Listen(s.network, s.GetAddress()) if err != nil { return fmt.Errorf("could not start HTTP server for challenge: %w", err) @@ -56,7 +57,9 @@ func (s *ProviderServer) Present(domain, token, keyAuth string) error { } s.done = make(chan bool) + go s.serve(domain, token, keyAuth) + return nil } @@ -69,8 +72,11 @@ func (s *ProviderServer) CleanUp(domain, token, keyAuth string) error { if s.listener == nil { return nil } + s.listener.Close() + <-s.done + return nil } @@ -107,19 +113,24 @@ func (s *ProviderServer) serve(domain, token, keyAuth string) { mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && s.matcher.matches(r, domain) { w.Header().Set("Content-Type", "text/plain") + _, err := w.Write([]byte(keyAuth)) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + log.Infof("[%s] Served key authentication", domain) - } else { - log.Warnf("Received request for domain %s with method %s but the domain did not match any challenge. Please ensure you are passing the %s header properly.", r.Host, r.Method, s.matcher.name()) - _, err := w.Write([]byte("TEST")) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + + return + } + + log.Warnf("Received request for domain %s with method %s but the domain did not match any challenge. Please ensure you are passing the %s header properly.", r.Host, r.Method, s.matcher.name()) + + _, err := w.Write([]byte("TEST")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return } }) @@ -133,5 +144,6 @@ func (s *ProviderServer) serve(domain, token, keyAuth string) { if err != nil && !strings.Contains(err.Error(), "use of closed network connection") { log.Println(err) } + s.done <- true } diff --git a/challenge/http01/http_challenge_test.go b/challenge/http01/http_challenge_test.go index 3a5aa6bbe..06c555e42 100644 --- a/challenge/http01/http_challenge_test.go +++ b/challenge/http01/http_challenge_test.go @@ -67,7 +67,7 @@ func TestProviderServer_GetAddress(t *testing.T) { } func TestChallenge(t *testing.T) { - _, apiURL := tester.SetupFakeAPI(t) + server := tester.MockACMEServer().BuildHTTPS(t) providerServer := NewProviderServer("", "23457") @@ -88,6 +88,7 @@ func TestChallenge(t *testing.T) { if err != nil { return err } + bodyStr := string(body) if bodyStr != chlng.KeyAuthorization { @@ -97,10 +98,10 @@ func TestChallenge(t *testing.T) { return nil } - privateKey, err := rsa.GenerateKey(rand.Reader, 512) + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) solver := NewChallenge(core, validate, providerServer) @@ -123,7 +124,7 @@ func TestChallengeUnix(t *testing.T) { t.Skip("only for UNIX systems") } - _, apiURL := tester.SetupFakeAPI(t) + server := tester.MockACMEServer().BuildHTTPS(t) dir := t.TempDir() t.Cleanup(func() { _ = os.RemoveAll(dir) }) @@ -157,6 +158,7 @@ func TestChallengeUnix(t *testing.T) { if err != nil { return err } + bodyStr := string(body) if bodyStr != chlng.KeyAuthorization { @@ -166,10 +168,10 @@ func TestChallengeUnix(t *testing.T) { return nil } - privateKey, err := rsa.GenerateKey(rand.Reader, 512) + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) solver := NewChallenge(core, validate, providerServer) @@ -188,12 +190,12 @@ func TestChallengeUnix(t *testing.T) { } func TestChallengeInvalidPort(t *testing.T) { - _, apiURL := tester.SetupFakeAPI(t) + server := tester.MockACMEServer().BuildHTTPS(t) - privateKey, err := rsa.GenerateKey(rand.Reader, 128) + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) validate := func(_ *api.Core, _ string, _ acme.Challenge) error { return nil } @@ -224,6 +226,7 @@ func (h *testProxyHeader) update(r *http.Request) { if h == nil || len(h.values) == 0 { return } + if h.name == "Host" { r.Host = h.values[0] } else if h.name != "" { @@ -371,7 +374,7 @@ func TestChallengeWithProxy(t *testing.T) { func testServeWithProxy(t *testing.T, header, extra *testProxyHeader, expectError bool) { t.Helper() - _, apiURL := tester.SetupFakeAPI(t) + server := tester.MockACMEServer().BuildHTTPS(t) providerServer := NewProviderServer("localhost", "23457") if header != nil { @@ -385,6 +388,7 @@ func testServeWithProxy(t *testing.T, header, extra *testProxyHeader, expectErro if err != nil { return err } + header.update(req) extra.update(req) @@ -402,6 +406,7 @@ func testServeWithProxy(t *testing.T, header, extra *testProxyHeader, expectErro if err != nil { return err } + bodyStr := string(body) if bodyStr != chlng.KeyAuthorization { @@ -411,10 +416,10 @@ func testServeWithProxy(t *testing.T, header, extra *testProxyHeader, expectErro return nil } - privateKey, err := rsa.GenerateKey(rand.Reader, 512) + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) solver := NewChallenge(core, validate, providerServer) diff --git a/challenge/resolver/errors.go b/challenge/resolver/errors.go index 94ccbd76a..65a6ccdb7 100644 --- a/challenge/resolver/errors.go +++ b/challenge/resolver/errors.go @@ -3,6 +3,8 @@ package resolver import ( "bytes" "fmt" + "maps" + "slices" "sort" ) @@ -16,10 +18,16 @@ func (e obtainError) Error() string { for domain := range e { domains = append(domains, domain) } + sort.Strings(domains) for _, domain := range domains { _, _ = fmt.Fprintf(buffer, "[%s] %s\n", domain, e[domain]) } + return buffer.String() } + +func (e obtainError) Unwrap() []error { + return slices.AppendSeq(make([]error, 0, len(e)), maps.Values(e)) +} diff --git a/challenge/resolver/errors_test.go b/challenge/resolver/errors_test.go new file mode 100644 index 000000000..d4ab3c481 --- /dev/null +++ b/challenge/resolver/errors_test.go @@ -0,0 +1,70 @@ +package resolver + +import ( + "errors" + "testing" + + "github.com/go-acme/lego/v4/acme" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_obtainError_Error(t *testing.T) { + err := obtainError{ + "a": &acme.ProblemDetails{Type: "001"}, + "b": errors.New("oops"), + "c": errors.New("I did it again"), + } + + require.EqualError(t, err, `error: one or more domains had a problem: +[a] acme: error: 0 :: 001 :: +[b] oops +[c] I did it again +`) +} + +func Test_obtainError_Unwrap(t *testing.T) { + testCases := []struct { + desc string + err obtainError + assert assert.BoolAssertionFunc + }{ + { + desc: "one ok", + err: obtainError{ + "a": &acme.ProblemDetails{}, + "b": errors.New("oops"), + "c": errors.New("I did it again"), + }, + assert: assert.True, + }, + { + desc: "all ok", + err: obtainError{ + "a": &acme.ProblemDetails{Type: "001"}, + "b": &acme.ProblemDetails{Type: "002"}, + "c": &acme.ProblemDetails{Type: "002"}, + }, + assert: assert.True, + }, + { + desc: "nope", + err: obtainError{ + "a": errors.New("hello"), + "b": errors.New("oops"), + "c": errors.New("I did it again"), + }, + assert: assert.False, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + var pd *acme.ProblemDetails + + test.assert(t, errors.As(test.err, &pd)) + }) + } +} diff --git a/challenge/resolver/prober.go b/challenge/resolver/prober.go index 021facbb5..66b12c7a7 100644 --- a/challenge/resolver/prober.go +++ b/challenge/resolver/prober.go @@ -50,11 +50,14 @@ func NewProber(solverManager *SolverManager) *Prober { func (p *Prober) Solve(authorizations []acme.Authorization) error { failures := make(obtainError) - var authSolvers []*selectedAuthSolver - var authSolversSequential []*selectedAuthSolver + var ( + authSolvers []*selectedAuthSolver + authSolversSequential []*selectedAuthSolver + ) // Loop through the resources, basically through the domains. // First pass just selects a solver for each authz. + for _, authz := range authorizations { domain := challenge.GetTargetedDomain(authz) if authz.Status == acme.StatusValid { @@ -90,47 +93,88 @@ func (p *Prober) Solve(authorizations []acme.Authorization) error { if len(failures) > 0 { return failures } + return nil } func sequentialSolve(authSolvers []*selectedAuthSolver, failures obtainError) { + // Some CA are using the same token, + // this can be a problem with the DNS01 challenge when the DNS provider doesn't support duplicate TXT records. + // In the sequential mode, this is not a problem because we can solve the challenges in order. + // But it can reduce the number of call the DNS provider APIs. + uniq := make(map[string]struct{}) + for i, authSolver := range authSolvers { // Submit the challenge domain := challenge.GetTargetedDomain(authSolver.authz) + chlg, _ := challenge.FindChallenge(challenge.DNS01, authSolver.authz) + if solvr, ok := authSolver.solver.(preSolver); ok { + if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok && chlg.Token != "" { + log.Infof("acme: duplicate token for %q (DNS-01); skipping pre-solve.", authSolver.authz.Identifier.Value) + continue + } + err := solvr.PreSolve(authSolver.authz) if err != nil { failures[domain] = err + cleanUp(authSolver.solver, authSolver.authz) + continue } + + uniq[authSolver.authz.Identifier.Value+chlg.Token] = struct{}{} } // Solve challenge err := authSolver.solver.Solve(authSolver.authz) if err != nil { failures[domain] = err + cleanUp(authSolver.solver, authSolver.authz) + continue } - // Clean challenge - cleanUp(authSolver.solver, authSolver.authz) + if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok || chlg.Token == "" { + // Clean challenge + cleanUp(authSolver.solver, authSolver.authz) - if len(authSolvers)-1 > i { - solvr := authSolver.solver.(sequential) - _, interval := solvr.Sequential() - log.Infof("sequence: wait for %s", interval) - time.Sleep(interval) + if len(authSolvers)-1 > i { + solvr := authSolver.solver.(sequential) + _, interval := solvr.Sequential() + log.Infof("sequence: wait for %s", interval) + time.Sleep(interval) + } + + delete(uniq, authSolver.authz.Identifier.Value+chlg.Token) + } else { + log.Infof("acme: duplicate token for %q (DNS-01); skipping cleanup.", authSolver.authz.Identifier.Value) } } } func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) { + // Some CA are using the same token, + // this can be a problem with the DNS01 challenge when the DNS provider doesn't support duplicate TXT records. + uniq := make(map[string]struct{}) + // For all valid preSolvers, first submit the challenges, so they have max time to propagate for _, authSolver := range authSolvers { authz := authSolver.authz + + chlg, err := challenge.FindChallenge(challenge.DNS01, authz) + if err == nil { + if _, ok := uniq[authz.Identifier.Value+chlg.Token]; ok { + log.Infof("acme: duplicate token for %q (DNS-01); skipping pre-solve.", authSolver.authz.Identifier.Value) + continue + } + + uniq[authz.Identifier.Value+chlg.Token] = struct{}{} + } + if solvr, ok := authSolver.solver.(preSolver); ok { err := solvr.PreSolve(authz) if err != nil { @@ -142,6 +186,16 @@ func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) { defer func() { // Clean all created TXT records for _, authSolver := range authSolvers { + chlg, err := challenge.FindChallenge(challenge.DNS01, authSolver.authz) + if err == nil { + if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok { + delete(uniq, authSolver.authz.Identifier.Value+chlg.Token) + } else { + log.Infof("acme: duplicate token for %q (DNS-01); skipping cleanup.", authSolver.authz.Identifier.Value) + continue + } + } + cleanUp(authSolver.solver, authSolver.authz) } }() @@ -149,6 +203,7 @@ func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) { // Finally solve all challenges for real for _, authSolver := range authSolvers { authz := authSolver.authz + domain := challenge.GetTargetedDomain(authz) if failures[domain] != nil { // already failed in previous loop @@ -165,6 +220,7 @@ func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) { func cleanUp(solvr solver, authz acme.Authorization) { if solvr, ok := solvr.(cleanup); ok { domain := challenge.GetTargetedDomain(authz) + err := solvr.CleanUp(authz) if err != nil { log.Warnf("[%s] acme: cleaning up failed: %v ", domain, err) diff --git a/challenge/resolver/prober_mock_test.go b/challenge/resolver/prober_mock_test.go index 5a91fe075..dc7ad8dec 100644 --- a/challenge/resolver/prober_mock_test.go +++ b/challenge/resolver/prober_mock_test.go @@ -1,6 +1,7 @@ package resolver import ( + "fmt" "time" "github.com/go-acme/lego/v4/acme" @@ -11,34 +12,68 @@ type preSolverMock struct { preSolve map[string]error solve map[string]error cleanUp map[string]error + + preSolveCounter int + solveCounter int + cleanUpCounter int } func (s *preSolverMock) PreSolve(authorization acme.Authorization) error { + s.preSolveCounter++ + return s.preSolve[authorization.Identifier.Value] } func (s *preSolverMock) Solve(authorization acme.Authorization) error { + s.solveCounter++ + return s.solve[authorization.Identifier.Value] } func (s *preSolverMock) CleanUp(authorization acme.Authorization) error { + s.cleanUpCounter++ + return s.cleanUp[authorization.Identifier.Value] } +func (s *preSolverMock) String() string { + return fmt.Sprintf("PreSolve: %d, Solve: %d, CleanUp: %d", s.preSolveCounter, s.solveCounter, s.cleanUpCounter) +} + func createStubAuthorizationHTTP01(domain, status string) acme.Authorization { + return createStubAuthorization(domain, status, false, acme.Challenge{ + Type: challenge.HTTP01.String(), + Validated: time.Now(), + }) +} + +func createStubAuthorizationDNS01(domain string, wildcard bool) acme.Authorization { + var chlgs []acme.Challenge + + if wildcard { + chlgs = append(chlgs, acme.Challenge{ + Type: challenge.HTTP01.String(), + Validated: time.Now(), + }) + } + + chlgs = append(chlgs, acme.Challenge{ + Type: challenge.DNS01.String(), + Validated: time.Now(), + }) + + return createStubAuthorization(domain, acme.StatusProcessing, wildcard, chlgs...) +} + +func createStubAuthorization(domain, status string, wildcard bool, chlgs ...acme.Challenge) acme.Authorization { return acme.Authorization{ - Status: status, - Expires: time.Now(), + Wildcard: wildcard, + Status: status, + Expires: time.Now(), Identifier: acme.Identifier{ - Type: challenge.HTTP01.String(), + Type: "dns", Value: domain, }, - Challenges: []acme.Challenge{ - { - Type: challenge.HTTP01.String(), - Validated: time.Now(), - Error: nil, - }, - }, + Challenges: chlgs, } } diff --git a/challenge/resolver/prober_test.go b/challenge/resolver/prober_test.go index 4ee9b1b46..829b16883 100644 --- a/challenge/resolver/prober_test.go +++ b/challenge/resolver/prober_test.go @@ -2,19 +2,22 @@ package resolver import ( "errors" + "fmt" "testing" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/challenge" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestProber_Solve(t *testing.T) { testCases := []struct { - desc string - solvers map[challenge.Type]solver - authz []acme.Authorization - expectedError string + desc string + solvers map[challenge.Type]solver + authz []acme.Authorization + expectedError string + expectedCounters map[challenge.Type]string }{ { desc: "success", @@ -26,9 +29,33 @@ func TestProber_Solve(t *testing.T) { }, }, authz: []acme.Authorization{ - createStubAuthorizationHTTP01("acme.wtf", acme.StatusProcessing), - createStubAuthorizationHTTP01("lego.wtf", acme.StatusProcessing), - createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusProcessing), + createStubAuthorizationHTTP01("example.com", acme.StatusProcessing), + createStubAuthorizationHTTP01("example.org", acme.StatusProcessing), + createStubAuthorizationHTTP01("example.net", acme.StatusProcessing), + }, + expectedCounters: map[challenge.Type]string{ + challenge.HTTP01: "PreSolve: 3, Solve: 3, CleanUp: 3", + }, + }, + { + desc: "DNS-01 deduplicate", + solvers: map[challenge.Type]solver{ + challenge.DNS01: &preSolverMock{ + preSolve: map[string]error{}, + solve: map[string]error{}, + cleanUp: map[string]error{}, + }, + }, + authz: []acme.Authorization{ + createStubAuthorizationDNS01("a.example", false), + createStubAuthorizationDNS01("a.example", true), + createStubAuthorizationDNS01("b.example", false), + createStubAuthorizationDNS01("b.example", true), + createStubAuthorizationDNS01("c.example", true), + createStubAuthorizationDNS01("d.example", false), + }, + expectedCounters: map[challenge.Type]string{ + challenge.DNS01: "PreSolve: 4, Solve: 6, CleanUp: 4", }, }, { @@ -41,9 +68,12 @@ func TestProber_Solve(t *testing.T) { }, }, authz: []acme.Authorization{ - createStubAuthorizationHTTP01("acme.wtf", acme.StatusValid), - createStubAuthorizationHTTP01("lego.wtf", acme.StatusValid), - createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusValid), + createStubAuthorizationHTTP01("example.com", acme.StatusValid), + createStubAuthorizationHTTP01("example.org", acme.StatusValid), + createStubAuthorizationHTTP01("example.net", acme.StatusValid), + }, + expectedCounters: map[challenge.Type]string{ + challenge.HTTP01: "PreSolve: 0, Solve: 0, CleanUp: 0", }, }, { @@ -51,50 +81,56 @@ func TestProber_Solve(t *testing.T) { solvers: map[challenge.Type]solver{ challenge.HTTP01: &preSolverMock{ preSolve: map[string]error{ - "acme.wtf": errors.New("preSolve error acme.wtf"), + "example.com": errors.New("preSolve error example.com"), }, solve: map[string]error{ - "acme.wtf": errors.New("solve error acme.wtf"), + "example.com": errors.New("solve error example.com"), }, cleanUp: map[string]error{ - "acme.wtf": errors.New("clean error acme.wtf"), + "example.com": errors.New("clean error example.com"), }, }, }, authz: []acme.Authorization{ - createStubAuthorizationHTTP01("acme.wtf", acme.StatusProcessing), - createStubAuthorizationHTTP01("lego.wtf", acme.StatusProcessing), - createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusProcessing), + createStubAuthorizationHTTP01("example.com", acme.StatusProcessing), + createStubAuthorizationHTTP01("example.org", acme.StatusProcessing), + createStubAuthorizationHTTP01("example.net", acme.StatusProcessing), }, expectedError: `error: one or more domains had a problem: -[acme.wtf] preSolve error acme.wtf +[example.com] preSolve error example.com `, + expectedCounters: map[challenge.Type]string{ + challenge.HTTP01: "PreSolve: 3, Solve: 2, CleanUp: 3", + }, }, { desc: "errors at different stages", solvers: map[challenge.Type]solver{ challenge.HTTP01: &preSolverMock{ preSolve: map[string]error{ - "acme.wtf": errors.New("preSolve error acme.wtf"), + "example.com": errors.New("preSolve error example.com"), }, solve: map[string]error{ - "acme.wtf": errors.New("solve error acme.wtf"), - "lego.wtf": errors.New("solve error lego.wtf"), + "example.com": errors.New("solve error example.com"), + "example.org": errors.New("solve error example.org"), }, cleanUp: map[string]error{ - "mydomain.wtf": errors.New("clean error mydomain.wtf"), + "example.net": errors.New("clean error example.net"), }, }, }, authz: []acme.Authorization{ - createStubAuthorizationHTTP01("acme.wtf", acme.StatusProcessing), - createStubAuthorizationHTTP01("lego.wtf", acme.StatusProcessing), - createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusProcessing), + createStubAuthorizationHTTP01("example.com", acme.StatusProcessing), + createStubAuthorizationHTTP01("example.org", acme.StatusProcessing), + createStubAuthorizationHTTP01("example.net", acme.StatusProcessing), }, expectedError: `error: one or more domains had a problem: -[acme.wtf] preSolve error acme.wtf -[lego.wtf] solve error lego.wtf +[example.com] preSolve error example.com +[example.org] solve error example.org `, + expectedCounters: map[challenge.Type]string{ + challenge.HTTP01: "PreSolve: 3, Solve: 2, CleanUp: 3", + }, }, } @@ -112,6 +148,10 @@ func TestProber_Solve(t *testing.T) { } else { require.NoError(t, err) } + + for n, s := range test.solvers { + assert.Equal(t, test.expectedCounters[n], fmt.Sprintf("%s", s)) + } }) } } diff --git a/challenge/resolver/solver_manager.go b/challenge/resolver/solver_manager.go index 138060bc7..87cf6e2d8 100644 --- a/challenge/resolver/solver_manager.go +++ b/challenge/resolver/solver_manager.go @@ -1,13 +1,13 @@ package resolver import ( + "context" "errors" "fmt" "sort" - "strconv" "time" - "github.com/cenkalti/backoff/v4" + "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/challenge" @@ -15,6 +15,7 @@ import ( "github.com/go-acme/lego/v4/challenge/http01" "github.com/go-acme/lego/v4/challenge/tlsalpn01" "github.com/go-acme/lego/v4/log" + "github.com/go-acme/lego/v4/platform/wait" ) type byType []acme.Challenge @@ -36,14 +37,14 @@ func NewSolversManager(core *api.Core) *SolverManager { } // SetHTTP01Provider specifies a custom provider p that can solve the given HTTP-01 challenge. -func (c *SolverManager) SetHTTP01Provider(p challenge.Provider) error { - c.solvers[challenge.HTTP01] = http01.NewChallenge(c.core, validate, p) +func (c *SolverManager) SetHTTP01Provider(p challenge.Provider, opts ...http01.ChallengeOption) error { + c.solvers[challenge.HTTP01] = http01.NewChallenge(c.core, validate, p, opts...) return nil } // SetTLSALPN01Provider specifies a custom provider p that can solve the given TLS-ALPN-01 challenge. -func (c *SolverManager) SetTLSALPN01Provider(p challenge.Provider) error { - c.solvers[challenge.TLSALPN01] = tlsalpn01.NewChallenge(c.core, validate, p) +func (c *SolverManager) SetTLSALPN01Provider(p challenge.Provider, opts ...tlsalpn01.ChallengeOption) error { + c.solvers[challenge.TLSALPN01] = tlsalpn01.NewChallenge(c.core, validate, p, opts...) return nil } @@ -69,6 +70,7 @@ func (c *SolverManager) chooseSolver(authz acme.Authorization) solver { log.Infof("[%s] acme: use %s solver", domain, chlg.Type) return solvr } + log.Infof("[%s] acme: Could not find solver for: %s", domain, chlg.Type) } @@ -91,20 +93,20 @@ func validate(core *api.Core, domain string, chlg acme.Challenge) error { return nil } - ra, err := strconv.Atoi(chlng.RetryAfter) - if err != nil { + retryAfter, err := api.ParseRetryAfter(chlng.RetryAfter) + if err != nil || retryAfter == 0 { // The ACME server MUST return a Retry-After. - // If it doesn't, we'll just poll hard. + // If it doesn't, or if it's invalid, we'll just poll hard. // Boulder does not implement the ability to retry challenges or the Retry-After header. // https://github.com/letsencrypt/boulder/blob/master/docs/acme-divergences.md#section-82 - ra = 5 + retryAfter = 5 * time.Second } - initialInterval := time.Duration(ra) * time.Second + + ctx := context.Background() bo := backoff.NewExponentialBackOff() - bo.InitialInterval = initialInterval - bo.MaxInterval = 10 * initialInterval - bo.MaxElapsedTime = 100 * initialInterval + bo.InitialInterval = retryAfter + bo.MaxInterval = 10 * retryAfter // After the path is sent, the ACME server will access our server. // Repeatedly check the server for an updated status on our request. @@ -124,10 +126,12 @@ func validate(core *api.Core, domain string, chlg acme.Challenge) error { return nil } - return errors.New("the server didn't respond to our request") + return fmt.Errorf("the server didn't respond to our request (status=%s)", authz.Status) } - return backoff.Retry(operation, bo) + return wait.Retry(ctx, operation, + backoff.WithBackOff(bo), + backoff.WithMaxElapsedTime(100*retryAfter)) } func checkChallengeStatus(chlng acme.ExtendedChallenge) (bool, error) { @@ -137,9 +141,9 @@ func checkChallengeStatus(chlng acme.ExtendedChallenge) (bool, error) { case acme.StatusPending, acme.StatusProcessing: return false, nil case acme.StatusInvalid: - return false, chlng.Error + return false, fmt.Errorf("invalid challenge: %w", chlng.Err()) default: - return false, errors.New("the server returned an unexpected state") + return false, fmt.Errorf("the server returned an unexpected challenge status: %s", chlng.Status) } } @@ -154,11 +158,12 @@ func checkAuthorizationStatus(authz acme.Authorization) (bool, error) { case acme.StatusInvalid: for _, chlg := range authz.Challenges { if chlg.Status == acme.StatusInvalid && chlg.Error != nil { - return false, chlg.Error + return false, fmt.Errorf("invalid authorization: %w", chlg.Err()) } } - return false, fmt.Errorf("the authorization state %s", authz.Status) + + return false, errors.New("invalid authorization") default: - return false, errors.New("the server returned an unexpected state") + return false, fmt.Errorf("the server returned an unexpected authorization status: %s", authz.Status) } } diff --git a/challenge/resolver/solver_manager_test.go b/challenge/resolver/solver_manager_test.go index 9249beeba..77149c73a 100644 --- a/challenge/resolver/solver_manager_test.go +++ b/challenge/resolver/solver_manager_test.go @@ -12,6 +12,7 @@ import ( "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -32,70 +33,50 @@ func TestByType(t *testing.T) { } func TestValidate(t *testing.T) { - mux, apiURL := tester.SetupFakeAPI(t) - var statuses []string - privateKey, _ := rsa.GenerateKey(rand.Reader, 512) + privateKey, _ := rsa.GenerateKey(rand.Reader, 1024) - mux.HandleFunc("/chlg", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } + server := tester.MockACMEServer(). + Route("POST /chlg", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if err := validateNoBody(privateKey, req); err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } - if err := validateNoBody(privateKey, r); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } + rw.Header().Set("Link", + fmt.Sprintf(`; rel="up"`, req.Context().Value(http.LocalAddrContextKey))) - w.Header().Set("Link", "<"+apiURL+`/my-authz>; rel="up"`) + st := statuses[0] + statuses = statuses[1:] - st := statuses[0] - statuses = statuses[1:] + chlg := &acme.Challenge{Type: "http-01", Status: st, URL: "http://example.com/", Token: "token"} - chlg := &acme.Challenge{Type: "http-01", Status: st, URL: "http://example.com/", Token: "token"} - if st == acme.StatusInvalid { - chlg.Error = &acme.ProblemDetails{} - } + servermock.JSONEncode(chlg).ServeHTTP(rw, req) + })). + Route("POST /my-authz", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + st := statuses[0] + statuses = statuses[1:] - err := tester.WriteJSONResponse(w, chlg) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + authorization := acme.Authorization{ + Status: st, + Challenges: []acme.Challenge{}, + } - mux.HandleFunc("/my-authz", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } + if st == acme.StatusInvalid { + chlg := acme.Challenge{ + Status: acme.StatusInvalid, + } + authorization.Challenges = append(authorization.Challenges, chlg) + } - st := statuses[0] - statuses = statuses[1:] + servermock.JSONEncode(authorization).ServeHTTP(rw, req) + })). + BuildHTTPS(t) - authorization := acme.Authorization{ - Status: st, - Challenges: []acme.Challenge{}, - } - - if st == acme.StatusInvalid { - chlg := acme.Challenge{ - Status: acme.StatusInvalid, - Error: &acme.ProblemDetails{}, - } - authorization.Challenges = append(authorization.Challenges, chlg) - } - - err := tester.WriteJSONResponse(w, authorization) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) testCases := []struct { @@ -106,7 +87,7 @@ func TestValidate(t *testing.T) { { name: "POST-unexpected", statuses: []string{"weird"}, - want: "unexpected", + want: "the server returned an unexpected challenge status: weird", }, { name: "POST-valid", @@ -115,12 +96,12 @@ func TestValidate(t *testing.T) { { name: "POST-invalid", statuses: []string{acme.StatusInvalid}, - want: "error", + want: "invalid challenge:", }, { name: "POST-pending-unexpected", statuses: []string{acme.StatusPending, "weird"}, - want: "unexpected", + want: "the server returned an unexpected authorization status: weird", }, { name: "POST-pending-valid", @@ -129,7 +110,7 @@ func TestValidate(t *testing.T) { { name: "POST-pending-invalid", statuses: []string{acme.StatusPending, acme.StatusInvalid}, - want: "error", + want: "invalid authorization", }, } @@ -137,7 +118,7 @@ func TestValidate(t *testing.T) { t.Run(test.name, func(t *testing.T) { statuses = test.statuses - err := validate(core, "example.com", acme.Challenge{Type: "http-01", Token: "token", URL: apiURL + "/chlg"}) + err := validate(core, "example.com", acme.Challenge{Type: "http-01", Token: "token", URL: server.URL + "/chlg"}) if test.want == "" { require.NoError(t, err) } else { @@ -148,6 +129,126 @@ func TestValidate(t *testing.T) { } } +func Test_checkChallengeStatus(t *testing.T) { + testCases := []struct { + desc string + challenge acme.Challenge + requireErr require.ErrorAssertionFunc + expected bool + }{ + { + desc: "status valid", + challenge: acme.Challenge{Status: acme.StatusValid}, + requireErr: require.NoError, + expected: true, + }, + { + desc: "status invalid", + challenge: acme.Challenge{Status: acme.StatusInvalid}, + requireErr: require.Error, + expected: false, + }, + { + desc: "status invalid with error", + challenge: acme.Challenge{Status: acme.StatusInvalid, Error: &acme.ProblemDetails{}}, + requireErr: require.Error, + expected: false, + }, + { + desc: "status pending", + challenge: acme.Challenge{Status: acme.StatusPending}, + requireErr: require.NoError, + expected: false, + }, + { + desc: "status processing", + challenge: acme.Challenge{Status: acme.StatusProcessing}, + requireErr: require.NoError, + expected: false, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + status, err := checkChallengeStatus(acme.ExtendedChallenge{Challenge: test.challenge}) + test.requireErr(t, err) + + assert.Equal(t, test.expected, status) + }) + } +} + +func Test_checkAuthorizationStatus(t *testing.T) { + testCases := []struct { + desc string + authorization acme.Authorization + requireErr require.ErrorAssertionFunc + expected bool + }{ + { + desc: "status valid", + authorization: acme.Authorization{Status: acme.StatusValid}, + requireErr: require.NoError, + expected: true, + }, + { + desc: "status invalid", + authorization: acme.Authorization{Status: acme.StatusInvalid}, + requireErr: require.Error, + expected: false, + }, + { + desc: "status invalid with error", + authorization: acme.Authorization{Status: acme.StatusInvalid, Challenges: []acme.Challenge{{Error: &acme.ProblemDetails{}}}}, + requireErr: require.Error, + expected: false, + }, + { + desc: "status pending", + authorization: acme.Authorization{Status: acme.StatusPending}, + requireErr: require.NoError, + expected: false, + }, + { + desc: "status processing", + authorization: acme.Authorization{Status: acme.StatusProcessing}, + requireErr: require.NoError, + expected: false, + }, + { + desc: "status deactivated", + authorization: acme.Authorization{Status: acme.StatusDeactivated}, + requireErr: require.Error, + expected: false, + }, + { + desc: "status expired", + authorization: acme.Authorization{Status: acme.StatusExpired}, + requireErr: require.Error, + expected: false, + }, + { + desc: "status revoked", + authorization: acme.Authorization{Status: acme.StatusRevoked}, + requireErr: require.Error, + expected: false, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + status, err := checkAuthorizationStatus(test.authorization) + test.requireErr(t, err) + + assert.Equal(t, test.expected, status) + }) + } +} + // validateNoBody reads the http.Request POST body, parses the JWS and validates it to read the body. // If there is an error doing this, // or if the JWS body is not the empty JSON payload "{}" or a POST-as-GET payload "" an error is returned. @@ -159,6 +260,7 @@ func validateNoBody(privateKey *rsa.PrivateKey, r *http.Request) error { } sigAlgs := []jose.SignatureAlgorithm{jose.RS256} + jws, err := jose.ParseSigned(string(reqBody), sigAlgs) if err != nil { return err @@ -175,5 +277,6 @@ func validateNoBody(privateKey *rsa.PrivateKey, r *http.Request) error { if bodyStr := string(body); bodyStr != "{}" && bodyStr != "" { return fmt.Errorf(`expected JWS POST body "{}" or "", got %q`, bodyStr) } + return nil } diff --git a/challenge/tlsalpn01/tls_alpn_challenge.go b/challenge/tlsalpn01/tls_alpn_challenge.go index 04ba71507..d8e939106 100644 --- a/challenge/tlsalpn01/tls_alpn_challenge.go +++ b/challenge/tlsalpn01/tls_alpn_challenge.go @@ -7,6 +7,7 @@ import ( "crypto/x509/pkix" "encoding/asn1" "fmt" + "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" @@ -21,18 +22,38 @@ var idPeAcmeIdentifierV1 = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 31} type ValidateFunc func(core *api.Core, domain string, chlng acme.Challenge) error +type ChallengeOption func(*Challenge) error + +// SetDelay sets a delay between the start of the TLS listener and the challenge validation. +func SetDelay(delay time.Duration) ChallengeOption { + return func(chlg *Challenge) error { + chlg.delay = delay + return nil + } +} + type Challenge struct { core *api.Core validate ValidateFunc provider challenge.Provider + delay time.Duration } -func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider) *Challenge { - return &Challenge{ +func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider, opts ...ChallengeOption) *Challenge { + chlg := &Challenge{ core: core, validate: validate, provider: provider, } + + for _, opt := range opts { + err := opt(chlg) + if err != nil { + log.Infof("challenge option error: %v", err) + } + } + + return chlg } func (c *Challenge) SetProvider(provider challenge.Provider) { @@ -59,6 +80,7 @@ func (c *Challenge) Solve(authz acme.Authorization) error { if err != nil { return fmt.Errorf("[%s] acme: error presenting token: %w", challenge.GetTargetedDomain(authz), err) } + defer func() { err := c.provider.CleanUp(domain, chlng.Token, keyAuth) if err != nil { @@ -66,7 +88,12 @@ func (c *Challenge) Solve(authz acme.Authorization) error { } }() + if c.delay > 0 { + time.Sleep(c.delay) + } + chlng.KeyAuthorization = keyAuth + return c.validate(c.core, domain, chlng) } diff --git a/challenge/tlsalpn01/tls_alpn_challenge_test.go b/challenge/tlsalpn01/tls_alpn_challenge_test.go index 8725a1360..59c2d61bc 100644 --- a/challenge/tlsalpn01/tls_alpn_challenge_test.go +++ b/challenge/tlsalpn01/tls_alpn_challenge_test.go @@ -8,7 +8,6 @@ import ( "crypto/tls" "encoding/asn1" "net" - "net/http" "testing" "github.com/go-acme/lego/v4/acme" @@ -21,7 +20,7 @@ import ( ) func TestChallenge(t *testing.T) { - _, apiURL := tester.SetupFakeAPI(t) + server := tester.MockACMEServer().BuildHTTPS(t) domain := "localhost" port := "24457" @@ -43,6 +42,7 @@ func TestChallenge(t *testing.T) { assert.NotEmpty(t, remoteCert.Extensions, "Expected the challenge certificate to contain extensions") idx := -1 + for i, ext := range remoteCert.Extensions { if idPeAcmeIdentifierV1.Equal(ext.Id) { idx = i @@ -66,10 +66,10 @@ func TestChallenge(t *testing.T) { return nil } - privateKey, err := rsa.GenerateKey(rand.Reader, 512) + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) solver := NewChallenge( @@ -93,12 +93,12 @@ func TestChallenge(t *testing.T) { } func TestChallengeInvalidPort(t *testing.T) { - _, apiURL := tester.SetupFakeAPI(t) + server := tester.MockACMEServer().BuildHTTPS(t) - privateKey, err := rsa.GenerateKey(rand.Reader, 128) + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) solver := NewChallenge( @@ -123,7 +123,7 @@ func TestChallengeInvalidPort(t *testing.T) { } func TestChallengeIPaddress(t *testing.T) { - _, apiURL := tester.SetupFakeAPI(t) + server := tester.MockACMEServer().BuildHTTPS(t) domain := "127.0.0.1" port := "24457" @@ -146,31 +146,37 @@ func TestChallengeIPaddress(t *testing.T) { assert.True(t, net.ParseIP("127.0.0.1").Equal(remoteCert.IPAddresses[0]), "challenge certificate IPAddress ") assert.NotEmpty(t, remoteCert.Extensions, "Expected the challenge certificate to contain extensions") - var foundAcmeIdentifier bool - var extValue []byte + var ( + foundAcmeIdentifier bool + extValue []byte + ) + for _, ext := range remoteCert.Extensions { if idPeAcmeIdentifierV1.Equal(ext.Id) { assert.True(t, ext.Critical, "Expected the challenge certificate id-pe-acmeIdentifier extension to be marked as critical") + foundAcmeIdentifier = true extValue = ext.Value + break } } require.True(t, foundAcmeIdentifier, "Expected the challenge certificate to contain an extension with the id-pe-acmeIdentifier id,") + zBytes := sha256.Sum256([]byte(chlng.KeyAuthorization)) value, err := asn1.Marshal(zBytes[:sha256.Size]) require.NoError(t, err, "Expected marshaling of the keyAuth to return no error") - require.EqualValues(t, value, extValue, "Expected the challenge certificate id-pe-acmeIdentifier extension to contain the SHA-256 digest of the keyAuth") + require.Equal(t, value, extValue, "Expected the challenge certificate id-pe-acmeIdentifier extension to contain the SHA-256 digest of the keyAuth") return nil } - privateKey, err := rsa.GenerateKey(rand.Reader, 512) + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) solver := NewChallenge( diff --git a/cmd/accounts_storage.go b/cmd/accounts_storage.go index b3e4986dd..01db2faf8 100644 --- a/cmd/accounts_storage.go +++ b/cmd/accounts_storage.go @@ -2,10 +2,8 @@ package cmd import ( "crypto" - "crypto/x509" "encoding/json" "encoding/pem" - "errors" "net/url" "os" "path/filepath" @@ -18,6 +16,8 @@ import ( "github.com/urfave/cli/v2" ) +const userIDPlaceholder = "noemail@example.com" + const ( baseAccountsRootFolderName = "accounts" baseKeysFolderName = "keys" @@ -34,7 +34,7 @@ const ( // // rootUserPath: // -// ./.lego/accounts/localhost_14000/hubert@hubert.com/ +// ./.lego/accounts/localhost_14000/foo@example.com/ // │ │ │ └── userID ("email" option) // │ │ └── CA server ("server" option) // │ └── root accounts directory @@ -42,7 +42,7 @@ const ( // // keysPath: // -// ./.lego/accounts/localhost_14000/hubert@hubert.com/keys/ +// ./.lego/accounts/localhost_14000/foo@example.com/keys/ // │ │ │ │ └── root keys directory // │ │ │ └── userID ("email" option) // │ │ └── CA server ("server" option) @@ -51,7 +51,7 @@ const ( // // accountFilePath: // -// ./.lego/accounts/localhost_14000/hubert@hubert.com/account.json +// ./.lego/accounts/localhost_14000/foo@example.com/account.json // │ │ │ │ └── account file // │ │ │ └── userID ("email" option) // │ │ └── CA server ("server" option) @@ -59,6 +59,7 @@ const ( // └── "path" option type AccountsStorage struct { userID string + email string rootPath string rootUserPath string keysPath string @@ -68,8 +69,13 @@ type AccountsStorage struct { // NewAccountsStorage Creates a new AccountsStorage. func NewAccountsStorage(ctx *cli.Context) *AccountsStorage { - // TODO: move to account struct? Currently MUST pass email. - email := getEmail(ctx) + // TODO: move to account struct? + email := ctx.String(flgEmail) + + userID := email + if userID == "" { + userID = userIDPlaceholder + } serverURL, err := url.Parse(ctx.String(flgServer)) if err != nil { @@ -79,10 +85,11 @@ func NewAccountsStorage(ctx *cli.Context) *AccountsStorage { rootPath := filepath.Join(ctx.String(flgPath), baseAccountsRootFolderName) serverPath := strings.NewReplacer(":", "_", "/", string(os.PathSeparator)).Replace(serverURL.Host) accountsPath := filepath.Join(rootPath, serverPath) - rootUserPath := filepath.Join(accountsPath, email) + rootUserPath := filepath.Join(accountsPath, userID) return &AccountsStorage{ - userID: email, + userID: userID, + email: email, rootPath: rootPath, rootUserPath: rootUserPath, keysPath: filepath.Join(rootUserPath, baseKeysFolderName), @@ -98,6 +105,7 @@ func (s *AccountsStorage) ExistsAccountFilePath() bool { } else if err != nil { log.Fatal(err) } + return true } @@ -113,6 +121,10 @@ func (s *AccountsStorage) GetUserID() string { return s.userID } +func (s *AccountsStorage) GetEmail() string { + return s.email +} + func (s *AccountsStorage) Save(account *Account) error { jsonBytes, err := json.MarshalIndent(account, "", "\t") if err != nil { @@ -125,13 +137,14 @@ func (s *AccountsStorage) Save(account *Account) error { func (s *AccountsStorage) LoadAccount(privateKey crypto.PrivateKey) *Account { fileBytes, err := os.ReadFile(s.accountFilePath) if err != nil { - log.Fatalf("Could not load file for account %s: %v", s.userID, err) + log.Fatalf("Could not load file for account %s: %v", s.GetUserID(), err) } var account Account + err = json.Unmarshal(fileBytes, &account) if err != nil { - log.Fatalf("Could not parse file for account %s: %v", s.userID, err) + log.Fatalf("Could not parse file for account %s: %v", s.GetUserID(), err) } account.key = privateKey @@ -139,13 +152,14 @@ func (s *AccountsStorage) LoadAccount(privateKey crypto.PrivateKey) *Account { if account.Registration == nil || account.Registration.Body.Status == "" { reg, err := tryRecoverRegistration(s.ctx, privateKey) if err != nil { - log.Fatalf("Could not load account for %s. Registration is nil: %#v", s.userID, err) + log.Fatalf("Could not load account for %s. Registration is nil: %#v", s.GetUserID(), err) } account.Registration = reg + err = s.Save(&account) if err != nil { - log.Fatalf("Could not save account for %s. Registration is nil: %#v", s.userID, err) + log.Fatalf("Could not save account for %s. Registration is nil: %#v", s.GetUserID(), err) } } @@ -153,18 +167,19 @@ func (s *AccountsStorage) LoadAccount(privateKey crypto.PrivateKey) *Account { } func (s *AccountsStorage) GetPrivateKey(keyType certcrypto.KeyType) crypto.PrivateKey { - accKeyPath := filepath.Join(s.keysPath, s.userID+".key") + accKeyPath := filepath.Join(s.keysPath, s.GetUserID()+".key") if _, err := os.Stat(accKeyPath); os.IsNotExist(err) { - log.Printf("No key found for account %s. Generating a %s key.", s.userID, keyType) + log.Printf("No key found for account %s. Generating a %s key.", s.GetUserID(), keyType) s.createKeysFolder() privateKey, err := generatePrivateKey(accKeyPath, keyType) if err != nil { - log.Fatalf("Could not generate RSA private account key for account %s: %v", s.userID, err) + log.Fatalf("Could not generate RSA private account key for account %s: %v", s.GetUserID(), err) } log.Printf("Saved key to %s", accKeyPath) + return privateKey } @@ -178,7 +193,7 @@ func (s *AccountsStorage) GetPrivateKey(keyType certcrypto.KeyType) crypto.Priva func (s *AccountsStorage) createKeysFolder() { if err := createNonExistingFolder(s.keysPath); err != nil { - log.Fatalf("Could not check/create directory for account %s: %v", s.userID, err) + log.Fatalf("Could not check/create directory for account %s: %v", s.GetUserID(), err) } } @@ -195,6 +210,7 @@ func generatePrivateKey(file string, keyType certcrypto.KeyType) (crypto.Private defer certOut.Close() pemKey := certcrypto.PEMBlock(privateKey) + err = pem.Encode(certOut, pemKey) if err != nil { return nil, err @@ -209,16 +225,12 @@ func loadPrivateKey(file string) (crypto.PrivateKey, error) { return nil, err } - keyBlock, _ := pem.Decode(keyBytes) - - switch keyBlock.Type { - case "RSA PRIVATE KEY": - return x509.ParsePKCS1PrivateKey(keyBlock.Bytes) - case "EC PRIVATE KEY": - return x509.ParseECPrivateKey(keyBlock.Bytes) + privateKey, err := certcrypto.ParsePEMPrivateKey(keyBytes) + if err != nil { + return nil, err } - return nil, errors.New("unknown private key type") + return privateKey, nil } func tryRecoverRegistration(ctx *cli.Context, privateKey crypto.PrivateKey) (*registration.Resource, error) { @@ -236,5 +248,6 @@ func tryRecoverRegistration(ctx *cli.Context, privateKey crypto.PrivateKey) (*re if err != nil { return nil, err } + return reg, nil } diff --git a/cmd/certs_storage.go b/cmd/certs_storage.go index f9bcdade8..25ef58075 100644 --- a/cmd/certs_storage.go +++ b/cmd/certs_storage.go @@ -2,7 +2,6 @@ package cmd import ( "bytes" - "crypto" "crypto/x509" "encoding/json" "encoding/pem" @@ -159,6 +158,7 @@ func (s *CertificatesStorage) ExistsFile(domain, extension string) bool { } else if err != nil { log.Fatal(err) } + return true } @@ -233,27 +233,9 @@ func (s *CertificatesStorage) WritePFXFile(domain string, certRes *certificate.R return fmt.Errorf("unable to get certificate chain for domain %s: %w", domain, err) } - keyPemBlock, _ := pem.Decode(certRes.PrivateKey) - if keyPemBlock == nil { - return fmt.Errorf("unable to parse PrivateKey for domain %s", domain) - } - - var privateKey crypto.Signer - var keyErr error - - switch keyPemBlock.Type { - case "RSA PRIVATE KEY": - privateKey, keyErr = x509.ParsePKCS1PrivateKey(keyPemBlock.Bytes) - if keyErr != nil { - return fmt.Errorf("unable to load RSA PrivateKey for domain %s: %w", domain, keyErr) - } - case "EC PRIVATE KEY": - privateKey, keyErr = x509.ParseECPrivateKey(keyPemBlock.Bytes) - if keyErr != nil { - return fmt.Errorf("unable to load EC PrivateKey for domain %s: %w", domain, keyErr) - } - default: - return fmt.Errorf("unsupported PrivateKey type '%s' for domain %s", keyPemBlock.Type, domain) + privateKey, err := certcrypto.ParsePEMPrivateKey(certRes.PrivateKey) + if err != nil { + return fmt.Errorf("unable to parse PrivateKey for domain %s: %w", domain, err) } encoder, err := getPFXEncoder(s.pfxFormat) @@ -302,6 +284,7 @@ func getCertificateChain(certRes *certificate.Resource) ([]*x509.Certificate, er } var certChain []*x509.Certificate + for chainCertPemBlock != nil { chainCert, err := x509.ParseCertificate(chainCertPemBlock.Bytes) if err != nil { @@ -317,6 +300,7 @@ func getCertificateChain(certRes *certificate.Resource) ([]*x509.Certificate, er func getPFXEncoder(pfxFormat string) (*pkcs12.Encoder, error) { var encoder *pkcs12.Encoder + switch pfxFormat { case "SHA256": encoder = pkcs12.Modern2023 @@ -337,5 +321,6 @@ func sanitizedDomain(domain string) string { if err != nil { log.Fatal(err) } + return safe } diff --git a/cmd/cmd_dnshelp.go b/cmd/cmd_dnshelp.go index 1a61cac80..41adf4c8d 100644 --- a/cmd/cmd_dnshelp.go +++ b/cmd/cmd_dnshelp.go @@ -58,7 +58,7 @@ type errWriter struct { err error } -func (ew *errWriter) writeln(a ...interface{}) { +func (ew *errWriter) writeln(a ...any) { if ew.err != nil { return } @@ -66,7 +66,7 @@ func (ew *errWriter) writeln(a ...interface{}) { _, ew.err = fmt.Fprintln(ew.w, a...) } -func (ew *errWriter) writef(format string, a ...interface{}) { +func (ew *errWriter) writef(format string, a ...any) { if ew.err != nil { return } diff --git a/cmd/cmd_list.go b/cmd/cmd_list.go index bf7b232da..53cd12c3c 100644 --- a/cmd/cmd_list.go +++ b/cmd/cmd_list.go @@ -3,6 +3,7 @@ package cmd import ( "encoding/json" "fmt" + "net" "net/url" "os" "path/filepath" @@ -36,7 +37,7 @@ func createList() *cli.Command { // fake email, needed by NewAccountsStorage &cli.StringFlag{ Name: flgEmail, - Value: "unknown", + Value: "", Hidden: true, }, }, @@ -67,6 +68,7 @@ func listCertificates(ctx *cli.Context) error { if !names { fmt.Println("No certificates found.") } + return nil } @@ -99,6 +101,11 @@ func listCertificates(ctx *cli.Context) error { } else { fmt.Println(" Certificate Name:", name) fmt.Println(" Domains:", strings.Join(pCert.DNSNames, ", ")) + + if len(pCert.IPAddresses) > 0 { + fmt.Println(" IPs:", formatIPAddresses(pCert.IPAddresses)) + } + fmt.Println(" Expiry Date:", pCert.NotAfter) fmt.Println(" Certificate Path:", filename) fmt.Println() @@ -122,6 +129,7 @@ func listAccount(ctx *cli.Context) error { } fmt.Println("Found the following accounts:") + for _, filename := range matches { data, err := os.ReadFile(filename) if err != nil { @@ -129,6 +137,7 @@ func listAccount(ctx *cli.Context) error { } var account Account + err = json.Unmarshal(data, &account) if err != nil { return err @@ -147,3 +156,12 @@ func listAccount(ctx *cli.Context) error { return nil } + +func formatIPAddresses(ipAddresses []net.IP) string { + var ips []string + for _, ip := range ipAddresses { + ips = append(ips, ip.String()) + } + + return strings.Join(ips, ", ") +} diff --git a/cmd/cmd_renew.go b/cmd/cmd_renew.go index 1f9c08168..4b41ebc78 100644 --- a/cmd/cmd_renew.go +++ b/cmd/cmd_renew.go @@ -20,22 +20,15 @@ import ( // Flag names. const ( - flgDays = "days" + flgRenewDays = "days" + flgRenewDynamic = "dynamic" flgARIDisable = "ari-disable" flgARIWaitToRenewDuration = "ari-wait-to-renew-duration" flgReuseKey = "reuse-key" flgRenewHook = "renew-hook" + flgRenewHookTimeout = "renew-hook-timeout" flgNoRandomSleep = "no-random-sleep" -) - -const ( - renewEnvAccountEmail = "LEGO_ACCOUNT_EMAIL" - renewEnvCertDomain = "LEGO_CERT_DOMAIN" - renewEnvCertPath = "LEGO_CERT_PATH" - renewEnvCertKeyPath = "LEGO_CERT_KEY_PATH" - renewEnvIssuerCertKeyPath = "LEGO_ISSUER_CERT_PATH" - renewEnvCertPEMPath = "LEGO_CERT_PEM_PATH" - renewEnvCertPFXPath = "LEGO_CERT_PFX_PATH" + flgForceCertDomains = "force-cert-domains" ) func createRenew() *cli.Command { @@ -46,24 +39,37 @@ func createRenew() *cli.Command { Before: func(ctx *cli.Context) error { // we require either domains or csr, but not both hasDomains := len(ctx.StringSlice(flgDomains)) > 0 + hasCsr := ctx.String(flgCSR) != "" if hasDomains && hasCsr { - log.Fatal("Please specify either --%s/-d or --%s/-c, but not both", flgDomains, flgCSR) + log.Fatalf("Please specify either --%s/-d or --%s/-c, but not both", flgDomains, flgCSR) } + if !hasDomains && !hasCsr { - log.Fatal("Please specify --%s/-d (or --%s/-c if you already have a CSR)", flgDomains, flgCSR) + log.Fatalf("Please specify --%s/-d (or --%s/-c if you already have a CSR)", flgDomains, flgCSR) } + + if ctx.Bool(flgForceCertDomains) && hasCsr { + log.Fatalf("--%s only works with --%s/-d, --%s/-c doesn't support this option.", flgForceCertDomains, flgDomains, flgCSR) + } + return nil }, Flags: []cli.Flag{ &cli.IntFlag{ - Name: flgDays, + Name: flgRenewDays, Value: 30, Usage: "The number of days left on a certificate to renew it.", }, + // TODO(ldez): in v5, remove this flag, use this behavior as default. + &cli.BoolFlag{ + Name: flgRenewDynamic, + Value: false, + Usage: "Compute dynamically, based on the lifetime of the certificate(s), when to renew: use 1/3rd of the lifetime left, or 1/2 of the lifetime for short-lived certificates). This supersedes --days and will be the default behavior in Lego v5.", + }, &cli.BoolFlag{ Name: flgARIDisable, - Usage: "Do not use the renewalInfo endpoint (draft-ietf-acme-ari) to check if a certificate should be renewed.", + Usage: "Do not use the renewalInfo endpoint (RFC9773) to check if a certificate should be renewed.", }, &cli.DurationFlag{ Name: flgARIWaitToRenewDuration, @@ -97,6 +103,10 @@ func createRenew() *cli.Command { Usage: "If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name." + " If no match, the default offered chain will be used.", }, + &cli.StringFlag{ + Name: flgProfile, + Usage: "If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one.", + }, &cli.StringFlag{ Name: flgAlwaysDeactivateAuthorizations, Usage: "Force the authorizations to be relinquished even if the certificate request was successful.", @@ -105,18 +115,26 @@ func createRenew() *cli.Command { Name: flgRenewHook, Usage: "Define a hook. The hook is executed only when the certificates are effectively renewed.", }, + &cli.DurationFlag{ + Name: flgRenewHookTimeout, + Usage: "Define the timeout for the hook execution.", + Value: 2 * time.Minute, + }, &cli.BoolFlag{ Name: flgNoRandomSleep, Usage: "Do not add a random sleep before the renewal." + " We do not recommend using this flag if you are doing your renewals in an automated way.", }, + &cli.BoolFlag{ + Name: flgForceCertDomains, + Usage: "Check and ensure that the cert's domain list matches those passed in the domains argument.", + }, }, } } func renew(ctx *cli.Context) error { - account, client := setup(ctx, NewAccountsStorage(ctx)) - setupChallenges(ctx, client) + account, keyType := setupAccount(ctx, NewAccountsStorage(ctx)) if account.Registration == nil { log.Fatalf("Account %s is not registered. Use 'run' to register a new account.\n", account.Email) @@ -126,18 +144,20 @@ func renew(ctx *cli.Context) error { bundle := !ctx.Bool(flgNoBundle) - meta := map[string]string{renewEnvAccountEmail: account.Email} + meta := map[string]string{ + hookEnvAccountEmail: account.Email, + } // CSR if ctx.IsSet(flgCSR) { - return renewForCSR(ctx, client, certsStorage, bundle, meta) + return renewForCSR(ctx, account, keyType, certsStorage, bundle, meta) } // Domains - return renewForDomains(ctx, client, certsStorage, bundle, meta) + return renewForDomains(ctx, account, keyType, certsStorage, bundle, meta) } -func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error { +func renewForDomains(ctx *cli.Context, account *Account, keyType certcrypto.KeyType, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error { domains := ctx.StringSlice(flgDomains) domain := domains[0] @@ -151,10 +171,16 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif cert := certificates[0] - var ariRenewalTime *time.Time - var replacesCertID string + var ( + ariRenewalTime *time.Time + replacesCertID string + ) + + var client *lego.Client if !ctx.Bool(flgARIDisable) { + client = setupClient(ctx, account, keyType) + ariRenewalTime = getARIRenewalTime(ctx, cert, domain, client) if ariRenewalTime != nil { now := time.Now().UTC() @@ -172,17 +198,25 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif } } - if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgDays)) { + forceDomains := ctx.Bool(flgForceCertDomains) + + certDomains := certcrypto.ExtractDomains(cert) + + if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgRenewDays), ctx.Bool(flgRenewDynamic)) && + (!forceDomains || slices.Equal(certDomains, domains)) { return nil } + if client == nil { + client = setupClient(ctx, account, keyType) + } + // This is just meant to be informal for the user. timeLeft := cert.NotAfter.Sub(time.Now().UTC()) log.Infof("[%s] acme: Trying renewal with %d hours remaining", domain, int(timeLeft.Hours())) - certDomains := certcrypto.ExtractDomains(cert) - var privateKey crypto.PrivateKey + if ctx.Bool(flgReuseKey) { keyBytes, errR := certsStorage.ReadFile(domain, keyExt) if errR != nil { @@ -200,6 +234,7 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif if !isatty.IsTerminal(os.Stdout.Fd()) && !ctx.Bool(flgNoRandomSleep) { // https://github.com/certbot/certbot/blob/284023a1b7672be2bd4018dd7623b3b92197d4b0/certbot/certbot/_internal/renewal.py#L472 const jitter = 8 * time.Minute + rnd := rand.New(rand.NewSource(time.Now().UnixNano())) sleepTime := time.Duration(rnd.Int63n(int64(jitter))) @@ -207,14 +242,20 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif time.Sleep(sleepTime) } + renewalDomains := slices.Clone(domains) + if !forceDomains { + renewalDomains = merge(certDomains, domains) + } + request := certificate.ObtainRequest{ - Domains: merge(certDomains, domains), + Domains: renewalDomains, PrivateKey: privateKey, MustStaple: ctx.Bool(flgMustStaple), NotBefore: getTime(ctx, flgNotBefore), NotAfter: getTime(ctx, flgNotAfter), Bundle: bundle, PreferredChain: ctx.String(flgPreferredChain), + Profile: ctx.String(flgProfile), AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations), } @@ -227,14 +268,16 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif log.Fatal(err) } + certRes.Domain = domain + certsStorage.SaveResource(certRes) addPathToMetadata(meta, domain, certRes, certsStorage) - return launchHook(ctx.String(flgRenewHook), meta) + return launchHook(ctx.String(flgRenewHook), ctx.Duration(flgRenewHookTimeout), meta) } -func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error { +func renewForCSR(ctx *cli.Context, account *Account, keyType certcrypto.KeyType, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error { csr, err := readCSRFile(ctx.String(flgCSR)) if err != nil { log.Fatal(err) @@ -255,10 +298,16 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat cert := certificates[0] - var ariRenewalTime *time.Time - var replacesCertID string + var ( + ariRenewalTime *time.Time + replacesCertID string + ) + + var client *lego.Client if !ctx.Bool(flgARIDisable) { + client = setupClient(ctx, account, keyType) + ariRenewalTime = getARIRenewalTime(ctx, cert, domain, client) if ariRenewalTime != nil { now := time.Now().UTC() @@ -276,10 +325,14 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat } } - if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgDays)) { + if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgRenewDays), ctx.Bool(flgRenewDynamic)) { return nil } + if client == nil { + client = setupClient(ctx, account, keyType) + } + // This is just meant to be informal for the user. timeLeft := cert.NotAfter.Sub(time.Now().UTC()) log.Infof("[%s] acme: Trying renewal with %d hours remaining", domain, int(timeLeft.Hours())) @@ -290,6 +343,7 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat NotAfter: getTime(ctx, flgNotAfter), Bundle: bundle, PreferredChain: ctx.String(flgPreferredChain), + Profile: ctx.String(flgProfile), AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations), } @@ -306,24 +360,51 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat addPathToMetadata(meta, domain, certRes, certsStorage) - return launchHook(ctx.String(flgRenewHook), meta) + return launchHook(ctx.String(flgRenewHook), ctx.Duration(flgRenewHookTimeout), meta) } -func needRenewal(x509Cert *x509.Certificate, domain string, days int) bool { +func needRenewal(x509Cert *x509.Certificate, domain string, days int, dynamic bool) bool { if x509Cert.IsCA { log.Fatalf("[%s] Certificate bundle starts with a CA certificate", domain) } - if days >= 0 { - notAfter := int(time.Until(x509Cert.NotAfter).Hours() / 24.0) - if notAfter > days { - log.Printf("[%s] The certificate expires in %d days, the number of days defined to perform the renewal is %d: no renewal.", - domain, notAfter, days) - return false - } + if dynamic { + return needRenewalDynamic(x509Cert, domain, time.Now()) } - return true + if days < 0 { + return true + } + + notAfter := int(time.Until(x509Cert.NotAfter).Hours() / 24.0) + if notAfter <= days { + return true + } + + log.Printf("[%s] The certificate expires in %d days, the number of days defined to perform the renewal is %d: no renewal.", + domain, notAfter, days) + + return false +} + +func needRenewalDynamic(x509Cert *x509.Certificate, domain string, now time.Time) bool { + lifetime := x509Cert.NotAfter.Sub(x509Cert.NotBefore) + + var divisor int64 = 3 + if lifetime.Round(24*time.Hour).Hours()/24.0 <= 10 { + divisor = 2 + } + + dueDate := x509Cert.NotAfter.Add(-1 * time.Duration(lifetime.Nanoseconds()/divisor)) + + if dueDate.Before(now) { + return true + } + + log.Infof("[%s] The certificate expires at %s, the renewal can be performed in %s: no renewal.", + domain, x509Cert.NotAfter.Format(time.RFC3339), dueDate.Sub(now)) + + return false } // getARIRenewalTime checks if the certificate needs to be renewed using the renewalInfo endpoint. @@ -339,16 +420,20 @@ func getARIRenewalTime(ctx *cli.Context, cert *x509.Certificate, domain string, log.Warnf("[%s] acme: %v", domain, err) return nil } + log.Warnf("[%s] acme: calling renewal info endpoint: %v", domain, err) + return nil } now := time.Now().UTC() + renewalTime := renewalInfo.ShouldRenewAt(now, ctx.Duration(flgARIWaitToRenewDuration)) if renewalTime == nil { log.Infof("[%s] acme: renewalInfo endpoint indicates that renewal is not needed", domain) return nil } + log.Infof("[%s] acme: renewalInfo endpoint indicates that renewal is needed", domain) if renewalInfo.ExplanationURL != "" { @@ -358,24 +443,6 @@ func getARIRenewalTime(ctx *cli.Context, cert *x509.Certificate, domain string, return renewalTime } -func addPathToMetadata(meta map[string]string, domain string, certRes *certificate.Resource, certsStorage *CertificatesStorage) { - meta[renewEnvCertDomain] = domain - meta[renewEnvCertPath] = certsStorage.GetFileName(domain, certExt) - meta[renewEnvCertKeyPath] = certsStorage.GetFileName(domain, keyExt) - - if certRes.IssuerCertificate != nil { - meta[renewEnvIssuerCertKeyPath] = certsStorage.GetFileName(domain, issuerExt) - } - - if certsStorage.pem { - meta[renewEnvCertPEMPath] = certsStorage.GetFileName(domain, pemExt) - } - - if certsStorage.pfx { - meta[renewEnvCertPFXPath] = certsStorage.GetFileName(domain, pfxExt) - } -} - func merge(prevDomains, nextDomains []string) []string { for _, next := range nextDomains { if slices.Contains(prevDomains, next) { diff --git a/cmd/cmd_renew_test.go b/cmd/cmd_renew_test.go index f88ad74c5..2485c5240 100644 --- a/cmd/cmd_renew_test.go +++ b/cmd/cmd_renew_test.go @@ -108,9 +108,62 @@ func Test_needRenewal(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - actual := needRenewal(test.x509Cert, "foo.com", test.days) + actual := needRenewal(test.x509Cert, "foo.com", test.days, false) assert.Equal(t, test.expected, actual) }) } } + +func Test_needRenewalDynamic(t *testing.T) { + testCases := []struct { + desc string + now time.Time + notBefore, notAfter time.Time + expected assert.BoolAssertionFunc + }{ + { + desc: "higher than 1/3 of the certificate lifetime left (lifetime > 10 days)", + now: time.Date(2025, 1, 19, 1, 1, 1, 1, time.UTC), + notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC), + notAfter: time.Date(2025, 1, 30, 1, 1, 1, 1, time.UTC), + expected: assert.False, + }, + { + desc: "lower than 1/3 of the certificate lifetime left(lifetime > 10 days)", + now: time.Date(2025, 1, 21, 1, 1, 1, 1, time.UTC), + notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC), + notAfter: time.Date(2025, 1, 30, 1, 1, 1, 1, time.UTC), + expected: assert.True, + }, + { + desc: "higher than 1/2 of the certificate lifetime left (lifetime < 10 days)", + now: time.Date(2025, 1, 4, 1, 1, 1, 1, time.UTC), + notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC), + notAfter: time.Date(2025, 1, 10, 1, 1, 1, 1, time.UTC), + expected: assert.False, + }, + { + desc: "lower than 1/2 of the certificate lifetime left (lifetime < 10 days)", + now: time.Date(2025, 1, 6, 1, 1, 1, 1, time.UTC), + notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC), + notAfter: time.Date(2025, 1, 10, 1, 1, 1, 1, time.UTC), + expected: assert.True, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + x509Cert := &x509.Certificate{ + NotBefore: test.notBefore, + NotAfter: test.notAfter, + } + + ok := needRenewalDynamic(x509Cert, "example.com", test.now) + + test.expected(t, ok) + }) + } +} diff --git a/cmd/cmd_revoke.go b/cmd/cmd_revoke.go index 2ecfd3017..667bebe12 100644 --- a/cmd/cmd_revoke.go +++ b/cmd/cmd_revoke.go @@ -38,12 +38,14 @@ func createRevoke() *cli.Command { } func revoke(ctx *cli.Context) error { - acc, client := setup(ctx, NewAccountsStorage(ctx)) + account, keyType := setupAccount(ctx, NewAccountsStorage(ctx)) - if acc.Registration == nil { - log.Fatalf("Account %s is not registered. Use 'run' to register a new account.\n", acc.Email) + if account.Registration == nil { + log.Fatalf("Account %s is not registered. Use 'run' to register a new account.\n", account.Email) } + client := newClient(ctx, account, keyType) + certsStorage := NewCertificatesStorage(ctx) certsStorage.CreateRootFolder() diff --git a/cmd/cmd_run.go b/cmd/cmd_run.go index a1d4cd514..5924c4b66 100644 --- a/cmd/cmd_run.go +++ b/cmd/cmd_run.go @@ -20,9 +20,12 @@ const ( flgMustStaple = "must-staple" flgNotBefore = "not-before" flgNotAfter = "not-after" + flgPrivateKey = "private-key" flgPreferredChain = "preferred-chain" + flgProfile = "profile" flgAlwaysDeactivateAuthorizations = "always-deactivate-authorizations" flgRunHook = "run-hook" + flgRunHookTimeout = "run-hook-timeout" ) func createRun() *cli.Command { @@ -32,13 +35,16 @@ func createRun() *cli.Command { Before: func(ctx *cli.Context) error { // we require either domains or csr, but not both hasDomains := len(ctx.StringSlice(flgDomains)) > 0 + hasCsr := ctx.String(flgCSR) != "" if hasDomains && hasCsr { log.Fatal("Please specify either --domains/-d or --csr/-c, but not both") } + if !hasDomains && !hasCsr { log.Fatal("Please specify --domains/-d (or --csr/-c if you already have a CSR)") } + return nil }, Action: run, @@ -62,11 +68,19 @@ func createRun() *cli.Command { Usage: "Set the notAfter field in the certificate (RFC3339 format)", Layout: time.RFC3339, }, + &cli.StringFlag{ + Name: flgPrivateKey, + Usage: "Path to private key (in PEM encoding) for the certificate. By default, the private key is generated.", + }, &cli.StringFlag{ Name: flgPreferredChain, Usage: "If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name." + " If no match, the default offered chain will be used.", }, + &cli.StringFlag{ + Name: flgProfile, + Usage: "If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one.", + }, &cli.StringFlag{ Name: flgAlwaysDeactivateAuthorizations, Usage: "Force the authorizations to be relinquished even if the certificate request was successful.", @@ -75,26 +89,32 @@ func createRun() *cli.Command { Name: flgRunHook, Usage: "Define a hook. The hook is executed when the certificates are effectively created.", }, + &cli.DurationFlag{ + Name: flgRunHookTimeout, + Usage: "Define the timeout for the hook execution.", + Value: 2 * time.Minute, + }, }, } } const rootPathWarningMessage = `!!!! HEADS UP !!!! -Your account credentials have been saved in your Let's Encrypt +Your account credentials have been saved in your configuration directory at "%s". You should make a secure backup of this folder now. This -configuration directory will also contain certificates and -private keys obtained from Let's Encrypt so making regular -backups of this folder is ideal. +configuration directory will also contain private keys +generated by lego and certificates obtained from the ACME +server. Making regular backups of this folder is ideal. ` func run(ctx *cli.Context) error { accountsStorage := NewAccountsStorage(ctx) - account, client := setup(ctx, accountsStorage) - setupChallenges(ctx, client) + account, keyType := setupAccount(ctx, accountsStorage) + + client := setupClient(ctx, account, keyType) if account.Registration == nil { reg, err := register(ctx, client) @@ -123,12 +143,12 @@ func run(ctx *cli.Context) error { certsStorage.SaveResource(cert) meta := map[string]string{ - renewEnvAccountEmail: account.Email, + hookEnvAccountEmail: account.Email, } addPathToMetadata(meta, cert.Domain, cert, certsStorage) - return launchHook(ctx.String(flgRunHook), meta) + return launchHook(ctx.String(flgRunHook), ctx.Duration(flgRunHookTimeout), meta) } func handleTOS(ctx *cli.Context, client *lego.Client) bool { @@ -138,10 +158,12 @@ func handleTOS(ctx *cli.Context, client *lego.Client) bool { } reader := bufio.NewReader(os.Stdin) + log.Printf("Please review the TOS at %s", client.GetToSURL()) for { fmt.Println("Do you accept the TOS? Y/n") + text, err := reader.ReadString('\n') if err != nil { log.Fatalf("Could not read from console: %v", err) @@ -191,20 +213,22 @@ func obtainCertificate(ctx *cli.Context, client *lego.Client) (*certificate.Reso // obtain a certificate, generating a new private key request := certificate.ObtainRequest{ Domains: domains, - Bundle: bundle, MustStaple: ctx.Bool(flgMustStaple), + NotBefore: getTime(ctx, flgNotBefore), + NotAfter: getTime(ctx, flgNotAfter), + Bundle: bundle, PreferredChain: ctx.String(flgPreferredChain), + Profile: ctx.String(flgProfile), AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations), } - notBefore := ctx.Timestamp(flgNotBefore) - if notBefore != nil { - request.NotBefore = *notBefore - } + if ctx.IsSet(flgPrivateKey) { + var err error - notAfter := ctx.Timestamp(flgNotAfter) - if notAfter != nil { - request.NotAfter = *notAfter + request.PrivateKey, err = loadPrivateKey(ctx.String(flgPrivateKey)) + if err != nil { + return nil, fmt.Errorf("load private key: %w", err) + } } return client.Certificate.Obtain(request) @@ -223,8 +247,18 @@ func obtainCertificate(ctx *cli.Context, client *lego.Client) (*certificate.Reso NotAfter: getTime(ctx, flgNotAfter), Bundle: bundle, PreferredChain: ctx.String(flgPreferredChain), + Profile: ctx.String(flgProfile), AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations), } + if ctx.IsSet(flgPrivateKey) { + var err error + + request.PrivateKey, err = loadPrivateKey(ctx.String(flgPrivateKey)) + if err != nil { + return nil, fmt.Errorf("load private key: %w", err) + } + } + return client.Certificate.ObtainForCSR(request) } diff --git a/cmd/flags.go b/cmd/flags.go index 0a8024dff..c7e8371b6 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -16,6 +16,7 @@ const ( flgServer = "server" flgAcceptTOS = "accept-tos" flgEmail = "email" + flgDisableCommonName = "disable-cn" flgCSR = "csr" flgEAB = "eab" flgKID = "kid" @@ -25,12 +26,14 @@ const ( flgPath = "path" flgHTTP = "http" flgHTTPPort = "http.port" + flgHTTPDelay = "http.delay" flgHTTPProxyHeader = "http.proxy-header" flgHTTPWebroot = "http.webroot" flgHTTPMemcachedHost = "http.memcached-host" flgHTTPS3Bucket = "http.s3-bucket" flgTLS = "tls" flgTLSPort = "tls.port" + flgTLSDelay = "tls.delay" flgDNS = "dns" flgDNSDisableCP = "dns.disable-cp" flgDNSPropagationWait = "dns.propagation-wait" @@ -49,6 +52,18 @@ const ( flgUserAgent = "user-agent" ) +const ( + envEAB = "LEGO_EAB" + envEABHMAC = "LEGO_EAB_HMAC" + envEABKID = "LEGO_EAB_KID" + envEmail = "LEGO_EMAIL" + envPath = "LEGO_PATH" + envPFX = "LEGO_PFX" + envPFXFormat = "LEGO_PFX_FORMAT" + envPFXPassword = "LEGO_PFX_PASSWORD" + envServer = "LEGO_SERVER" +) + func CreateFlags(defaultPath string) []cli.Flag { return []cli.Flag{ &cli.StringSliceFlag{ @@ -59,7 +74,7 @@ func CreateFlags(defaultPath string) []cli.Flag { &cli.StringFlag{ Name: flgServer, Aliases: []string{"s"}, - EnvVars: []string{"LEGO_SERVER"}, + EnvVars: []string{envServer}, Usage: "CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client.", Value: lego.LEDirectoryProduction, }, @@ -71,8 +86,13 @@ func CreateFlags(defaultPath string) []cli.Flag { &cli.StringFlag{ Name: flgEmail, Aliases: []string{"m"}, + EnvVars: []string{envEmail}, Usage: "Email used for registration and recovery contact.", }, + &cli.BoolFlag{ + Name: flgDisableCommonName, + Usage: "Disable the use of the common name in the CSR.", + }, &cli.StringFlag{ Name: flgCSR, Aliases: []string{"c"}, @@ -80,17 +100,17 @@ func CreateFlags(defaultPath string) []cli.Flag { }, &cli.BoolFlag{ Name: flgEAB, - EnvVars: []string{"LEGO_EAB"}, + EnvVars: []string{envEAB}, Usage: "Use External Account Binding for account registration. Requires --kid and --hmac.", }, &cli.StringFlag{ Name: flgKID, - EnvVars: []string{"LEGO_EAB_KID"}, + EnvVars: []string{envEABKID}, Usage: "Key identifier from External CA. Used for External Account Binding.", }, &cli.StringFlag{ Name: flgHMAC, - EnvVars: []string{"LEGO_EAB_HMAC"}, + EnvVars: []string{envEABHMAC}, Usage: "MAC key from External CA. Should be in Base64 URL Encoding without padding format. Used for External Account Binding.", }, &cli.StringFlag{ @@ -105,7 +125,7 @@ func CreateFlags(defaultPath string) []cli.Flag { }, &cli.StringFlag{ Name: flgPath, - EnvVars: []string{"LEGO_PATH"}, + EnvVars: []string{envPath}, Usage: "Directory to use for storing the data.", Value: defaultPath, }, @@ -118,6 +138,11 @@ func CreateFlags(defaultPath string) []cli.Flag { Usage: "Set the port and interface to use for HTTP-01 based challenges to listen on. Supported: interface:port or :port.", Value: ":80", }, + &cli.DurationFlag{ + Name: flgHTTPDelay, + Usage: "Delay between the starts of the HTTP server (use for HTTP-01 based challenges) and the validation of the challenge.", + Value: 0, + }, &cli.StringFlag{ Name: flgHTTPProxyHeader, Usage: "Validate against this HTTP header when solving HTTP-01 based challenges behind a reverse proxy.", @@ -145,6 +170,11 @@ func CreateFlags(defaultPath string) []cli.Flag { Usage: "Set the port and interface to use for TLS-ALPN-01 based challenges to listen on. Supported: interface:port or :port.", Value: ":443", }, + &cli.DurationFlag{ + Name: flgTLSDelay, + Usage: "Delay between the start of the TLS listener (use for TLSALPN-01 based challenges) and the validation of the challenge.", + Value: 0, + }, &cli.StringFlag{ Name: flgDNS, Usage: "Solve a DNS-01 challenge using the specified provider. Can be mixed with other types of challenges. Run 'lego dnshelp' for help on usage.", @@ -192,19 +222,19 @@ func CreateFlags(defaultPath string) []cli.Flag { &cli.BoolFlag{ Name: flgPFX, Usage: "Generate an additional .pfx (PKCS#12) file by concatenating the .key and .crt and issuer .crt files together.", - EnvVars: []string{"LEGO_PFX"}, + EnvVars: []string{envPFX}, }, &cli.StringFlag{ Name: flgPFXPass, Usage: "The password used to encrypt the .pfx (PCKS#12) file.", Value: pkcs12.DefaultPassword, - EnvVars: []string{"LEGO_PFX_PASSWORD"}, + EnvVars: []string{envPFXPassword}, }, &cli.StringFlag{ Name: flgPFXFormat, Usage: "The encoding format to use when encrypting the .pfx (PCKS#12) file. Supported: RC2, DES, SHA256.", Value: "RC2", - EnvVars: []string{"LEGO_PFX_FORMAT"}, + EnvVars: []string{envPFXFormat}, }, &cli.IntFlag{ Name: flgCertTimeout, @@ -228,5 +258,6 @@ func getTime(ctx *cli.Context, name string) time.Time { if value == nil { return time.Time{} } + return *value } diff --git a/cmd/hook.go b/cmd/hook.go index 0b0ca4038..7883108b6 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -1,6 +1,7 @@ package cmd import ( + "bufio" "context" "errors" "fmt" @@ -8,32 +9,70 @@ import ( "os/exec" "strings" "time" + + "github.com/go-acme/lego/v4/certificate" ) -func launchHook(hook string, meta map[string]string) error { +const ( + hookEnvAccountEmail = "LEGO_ACCOUNT_EMAIL" + hookEnvCertDomain = "LEGO_CERT_DOMAIN" + hookEnvCertPath = "LEGO_CERT_PATH" + hookEnvCertKeyPath = "LEGO_CERT_KEY_PATH" + hookEnvIssuerCertKeyPath = "LEGO_ISSUER_CERT_PATH" + hookEnvCertPEMPath = "LEGO_CERT_PEM_PATH" + hookEnvCertPFXPath = "LEGO_CERT_PFX_PATH" +) + +func launchHook(hook string, timeout time.Duration, meta map[string]string) error { if hook == "" { return nil } - ctxCmd, cancel := context.WithTimeout(context.Background(), 120*time.Second) + ctxCmd, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() parts := strings.Fields(hook) - cmdCtx := exec.CommandContext(ctxCmd, parts[0], parts[1:]...) - cmdCtx.Env = append(os.Environ(), metaToEnv(meta)...) + cmd := exec.CommandContext(ctxCmd, parts[0], parts[1:]...) - output, err := cmdCtx.CombinedOutput() + cmd.Env = append(os.Environ(), metaToEnv(meta)...) - if len(output) > 0 { - fmt.Println(string(output)) + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("create pipe: %w", err) } - if errors.Is(ctxCmd.Err(), context.DeadlineExceeded) { - return errors.New("hook timed out") + cmd.Stderr = cmd.Stdout + + err = cmd.Start() + if err != nil { + return fmt.Errorf("start command: %w", err) } - return err + go func() { + <-ctxCmd.Done() + + if ctxCmd.Err() != nil { + _ = cmd.Process.Kill() + _ = stdout.Close() + } + }() + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + fmt.Println(scanner.Text()) + } + + err = cmd.Wait() + if err != nil { + if errors.Is(ctxCmd.Err(), context.DeadlineExceeded) { + return errors.New("hook timed out") + } + + return fmt.Errorf("wait command: %w", err) + } + + return nil } func metaToEnv(meta map[string]string) []string { @@ -45,3 +84,21 @@ func metaToEnv(meta map[string]string) []string { return envs } + +func addPathToMetadata(meta map[string]string, domain string, certRes *certificate.Resource, certsStorage *CertificatesStorage) { + meta[hookEnvCertDomain] = domain + meta[hookEnvCertPath] = certsStorage.GetFileName(domain, certExt) + meta[hookEnvCertKeyPath] = certsStorage.GetFileName(domain, keyExt) + + if certRes.IssuerCertificate != nil { + meta[hookEnvIssuerCertKeyPath] = certsStorage.GetFileName(domain, issuerExt) + } + + if certsStorage.pem { + meta[hookEnvCertPEMPath] = certsStorage.GetFileName(domain, pemExt) + } + + if certsStorage.pfx { + meta[hookEnvCertPFXPath] = certsStorage.GetFileName(domain, pfxExt) + } +} diff --git a/cmd/hook_test.go b/cmd/hook_test.go new file mode 100644 index 000000000..d643bba30 --- /dev/null +++ b/cmd/hook_test.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func Test_launchHook(t *testing.T) { + err := launchHook("echo foo", 1*time.Second, map[string]string{}) + require.NoError(t, err) +} + +func Test_launchHook_errors(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping test on Windows") + } + + testCases := []struct { + desc string + hook string + timeout time.Duration + expected string + }{ + { + desc: "kill the hook", + hook: "sleep 5", + timeout: 1 * time.Second, + expected: "hook timed out", + }, + { + desc: "context timeout on Start", + hook: "echo foo", + timeout: 1 * time.Nanosecond, + expected: "start command: context deadline exceeded", + }, + { + desc: "multiple short sleeps", + hook: "./testdata/sleepy.sh", + timeout: 1 * time.Second, + expected: "hook timed out", + }, + { + desc: "long sleep", + hook: "./testdata/sleeping_beauty.sh", + timeout: 1 * time.Second, + expected: "hook timed out", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + err := launchHook(test.hook, test.timeout, map[string]string{}) + require.EqualError(t, err, test.expected) + }) + } +} diff --git a/cmd/lego/main.go b/cmd/lego/main.go index 61a3d532a..c301a51f1 100644 --- a/cmd/lego/main.go +++ b/cmd/lego/main.go @@ -26,6 +26,7 @@ func main() { } var defaultPath string + cwd, err := os.Getwd() if err == nil { defaultPath = filepath.Join(cwd, ".lego") diff --git a/cmd/lego/zz_gen_version.go b/cmd/lego/zz_gen_version.go index db3cac8c6..cf9ad00ef 100644 --- a/cmd/lego/zz_gen_version.go +++ b/cmd/lego/zz_gen_version.go @@ -2,7 +2,7 @@ package main -const defaultVersion = "v4.20.0+dev-release" +const defaultVersion = "v4.32.0+dev-detach" var version = "" diff --git a/cmd/setup.go b/cmd/setup.go index 4a802ba13..6d15adad3 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -1,25 +1,38 @@ package cmd import ( - "crypto/tls" + "context" "crypto/x509" + "encoding/json" "encoding/pem" "fmt" + "io" "net/http" "os" "strings" "time" + "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/registration" + "github.com/hashicorp/go-retryablehttp" "github.com/urfave/cli/v2" ) const filePerm os.FileMode = 0o600 -func setup(ctx *cli.Context, accountsStorage *AccountsStorage) (*Account, *lego.Client) { +// setupClient creates a new client with challenge settings. +func setupClient(ctx *cli.Context, account *Account, keyType certcrypto.KeyType) *lego.Client { + client := newClient(ctx, account, keyType) + + setupChallenges(ctx, client) + + return client +} + +func setupAccount(ctx *cli.Context, accountsStorage *AccountsStorage) (*Account, certcrypto.KeyType) { keyType := getKeyType(ctx) privateKey := accountsStorage.GetPrivateKey(keyType) @@ -27,12 +40,10 @@ func setup(ctx *cli.Context, accountsStorage *AccountsStorage) (*Account, *lego. if accountsStorage.ExistsAccountFilePath() { account = accountsStorage.LoadAccount(privateKey) } else { - account = &Account{Email: accountsStorage.GetUserID(), key: privateKey} + account = &Account{Email: accountsStorage.GetEmail(), key: privateKey} } - client := newClient(ctx, account, keyType) - - return account, client + return account, keyType } func newClient(ctx *cli.Context, acc registration.User, keyType certcrypto.KeyType) *lego.Client { @@ -43,6 +54,7 @@ func newClient(ctx *cli.Context, acc registration.User, keyType certcrypto.KeyTy KeyType: keyType, Timeout: time.Duration(ctx.Int(flgCertTimeout)) * time.Second, OverallRequestLimit: ctx.Int(flgOverallRequestLimit), + DisableCommonName: ctx.Bool(flgDisableCommonName), } config.UserAgent = getUserAgent(ctx) @@ -51,11 +63,26 @@ func newClient(ctx *cli.Context, acc registration.User, keyType certcrypto.KeyTy } if ctx.Bool(flgTLSSkipVerify) { - config.HTTPClient.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + defaultTransport, ok := config.HTTPClient.Transport.(*http.Transport) + if ok { // This is always true because the default client used by the CLI defined the transport. + tr := defaultTransport.Clone() + tr.TLSClientConfig.InsecureSkipVerify = true + config.HTTPClient.Transport = tr } } + retryClient := retryablehttp.NewClient() + retryClient.RetryMax = 5 + retryClient.HTTPClient = config.HTTPClient + retryClient.CheckRetry = checkRetry + retryClient.Logger = nil + + if _, v := os.LookupEnv("LEGO_DEBUG_ACME_HTTP_CLIENT"); v { + retryClient.Logger = log.Logger + } + + config.HTTPClient = retryClient.StandardClient() + client, err := lego.NewClient(config) if err != nil { log.Fatalf("Could not create client: %v", err) @@ -87,15 +114,8 @@ func getKeyType(ctx *cli.Context) certcrypto.KeyType { } log.Fatalf("Unsupported KeyType: %s", keyType) - return "" -} -func getEmail(ctx *cli.Context) string { - email := ctx.String(flgEmail) - if email == "" { - log.Fatalf("You have to pass an account (email address) to the program using --%s or -m", flgEmail) - } - return email + return "" } func getUserAgent(ctx *cli.Context) string { @@ -108,6 +128,7 @@ func createNonExistingFolder(path string) error { } else if err != nil { return err } + return nil } @@ -116,10 +137,12 @@ func readCSRFile(filename string) (*x509.CertificateRequest, error) { if err != nil { return nil, err } + raw := bytes // see if we can find a PEM-encoded CSR var p *pem.Block + rest := bytes for { // decode a PEM block @@ -141,3 +164,49 @@ func readCSRFile(filename string) (*x509.CertificateRequest, error) { // (if this assumption is wrong, parsing these bytes will fail) return x509.ParseCertificateRequest(raw) } + +func checkRetry(ctx context.Context, resp *http.Response, err error) (bool, error) { + rt, err := retryablehttp.ErrorPropagatedRetryPolicy(ctx, resp, err) + if err != nil { + return rt, err + } + + if resp == nil { + return rt, nil + } + + if resp.StatusCode/100 == 2 { + return rt, nil + } + + all, err := io.ReadAll(resp.Body) + if err == nil { + var errorDetails *acme.ProblemDetails + + err = json.Unmarshal(all, &errorDetails) + if err != nil { + return rt, fmt.Errorf("%s %s: %s", resp.Request.Method, resp.Request.URL.Redacted(), string(all)) + } + + switch errorDetails.Type { + case acme.BadNonceErr: + return false, &acme.NonceError{ + ProblemDetails: errorDetails, + } + + case acme.AlreadyReplacedErr: + if errorDetails.HTTPStatus == http.StatusConflict { + return false, &acme.AlreadyReplacedError{ + ProblemDetails: errorDetails, + } + } + + default: + log.Warnf("retry: %v", errorDetails) + + return rt, errorDetails + } + } + + return rt, nil +} diff --git a/cmd/setup_challenges.go b/cmd/setup_challenges.go index 0a59099a8..6968c7ba3 100644 --- a/cmd/setup_challenges.go +++ b/cmd/setup_challenges.go @@ -25,14 +25,14 @@ func setupChallenges(ctx *cli.Context, client *lego.Client) { } if ctx.Bool(flgHTTP) { - err := client.Challenge.SetHTTP01Provider(setupHTTPProvider(ctx)) + err := client.Challenge.SetHTTP01Provider(setupHTTPProvider(ctx), http01.SetDelay(ctx.Duration(flgHTTPDelay))) if err != nil { log.Fatal(err) } } if ctx.Bool(flgTLS) { - err := client.Challenge.SetTLSALPN01Provider(setupTLSProvider(ctx)) + err := client.Challenge.SetTLSALPN01Provider(setupTLSProvider(ctx), tlsalpn01.SetDelay(ctx.Duration(flgTLSDelay))) if err != nil { log.Fatal(err) } @@ -54,18 +54,21 @@ func setupHTTPProvider(ctx *cli.Context) challenge.Provider { if err != nil { log.Fatal(err) } + return ps case ctx.IsSet(flgHTTPMemcachedHost): ps, err := memcached.NewMemcachedProvider(ctx.StringSlice(flgHTTPMemcachedHost)) if err != nil { log.Fatal(err) } + return ps case ctx.IsSet(flgHTTPS3Bucket): ps, err := s3.NewHTTPProvider(ctx.String(flgHTTPS3Bucket)) if err != nil { log.Fatal(err) } + return ps case ctx.IsSet(flgHTTPPort): iface := ctx.String(flgHTTPPort) @@ -82,12 +85,14 @@ func setupHTTPProvider(ctx *cli.Context) challenge.Provider { if header := ctx.String(flgHTTPProxyHeader); header != "" { srv.SetProxyHeader(header) } + return srv case ctx.Bool(flgHTTP): srv := http01.NewProviderServer("", "") if header := ctx.String(flgHTTPProxyHeader); header != "" { srv.SetProxyHeader(header) } + return srv default: log.Fatal("Invalid HTTP challenge options.") diff --git a/cmd/testdata/sleeping_beauty.sh b/cmd/testdata/sleeping_beauty.sh new file mode 100755 index 000000000..96b42a005 --- /dev/null +++ b/cmd/testdata/sleeping_beauty.sh @@ -0,0 +1,3 @@ +#!/bin/bash -e + +sleep 50 diff --git a/cmd/testdata/sleepy.sh b/cmd/testdata/sleepy.sh new file mode 100755 index 000000000..60bb903a1 --- /dev/null +++ b/cmd/testdata/sleepy.sh @@ -0,0 +1,7 @@ +#!/bin/bash -e + +for i in `seq 1 10` +do + echo $i + sleep 0.2 +done diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 52eb0f11f..f73f3920b 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -12,17 +12,28 @@ import ( func allDNSCodes() string { providers := []string{ - "manual", "acme-dns", + "active24", "alidns", + "aliesa", "allinkl", + "alwaysdata", + "anexia", + "artfiles", "arvancloud", "auroradns", "autodns", + "axelname", + "azion", "azure", "azuredns", + "baiducloud", + "beget", + "binarylane", "bindman", "bluecat", + "bluecatv2", + "bookmyname", "brandit", "bunny", "checkdomain", @@ -32,15 +43,20 @@ func allDNSCodes() string { "cloudns", "cloudru", "cloudxns", + "com35", "conoha", + "conohav3", "constellix", "corenetworks", "cpanel", + "czechia", + "ddnss", "derak", "desec", "designate", "digitalocean", "directadmin", + "dnsexit", "dnshomede", "dnsimple", "dnsmadeeasy", @@ -50,23 +66,33 @@ func allDNSCodes() string { "dreamhost", "duckdns", "dyn", + "dyndnsfree", "dynu", "easydns", + "edgecenter", "edgedns", + "edgeone", "efficientip", "epik", + "eurodns", + "excedo", "exec", "exoscale", + "f5xc", "freemyip", "gandi", "gandiv5", "gcloud", "gcore", + "gigahostno", "glesys", "godaddy", "googledomains", + "gravity", "hetzner", "hostingde", + "hostinger", + "hostingnl", "hosttech", "httpnet", "httpreq", @@ -81,9 +107,15 @@ func allDNSCodes() string { "internetbs", "inwx", "ionos", + "ionoscloud", "ipv64", + "ispconfig", + "ispconfigddns", "iwantmyname", + "jdcloud", "joker", + "keyhelp", + "leaseweb", "liara", "lightsail", "limacity", @@ -92,22 +124,30 @@ func allDNSCodes() string { "loopia", "luadns", "mailinabox", + "manageengine", + "manual", "metaname", + "metaregistrar", "mijnhost", "mittwald", + "myaddr", "mydnsjp", "mythicbeasts", "namecheap", "namedotcom", "namesilo", + "namesurfer", "nearlyfreespeech", + "neodigit", "netcup", "netlify", "nicmanager", + "nicru", "nifcloud", "njalla", "nodion", "ns1", + "octenium", "oraclecloud", "otc", "ovh", @@ -115,6 +155,7 @@ func allDNSCodes() string { "plesk", "porkbun", "rackspace", + "rainyun", "rcodezero", "regfish", "regru", @@ -131,27 +172,35 @@ func allDNSCodes() string { "shellrent", "simply", "sonic", + "spaceship", "stackpath", + "syse", "technitium", "tencentcloud", "timewebcloud", + "todaynic", "transip", "ultradns", + "uniteddomains", "variomedia", "vegadns", "vercel", "versio", "vinyldns", + "virtualname", "vkcloud", "volcengine", "vscale", "vultr", "webnames", + "webnamesca", "websupport", "wedos", + "westcn", "yandex", "yandex360", "yandexcloud", + "zoneedit", "zoneee", "zonomi", } @@ -173,12 +222,37 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Credentials:`) ew.writeln(` - "ACME_DNS_API_BASE": The ACME-DNS API address`) + ew.writeln(` - "ACME_DNS_STORAGE_BASE_URL": The ACME-DNS JSON account data server.`) ew.writeln(` - "ACME_DNS_STORAGE_PATH": The ACME-DNS JSON account data file. A per-domain account will be registered/persisted to this file and used for TXT updates.`) ew.writeln() + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "ACME_DNS_ALLOWLIST": Source networks using CIDR notation (multiple values should be separated with a comma).`) + ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/acme-dns`) + case "active24": + // generated from: providers/dns/active24/active24.toml + ew.writeln(`Configuration for Active24.`) + ew.writeln(`Code: 'active24'`) + ew.writeln(`Since: 'v4.23.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "ACTIVE24_API_KEY": API key`) + ew.writeln(` - "ACTIVE24_SECRET": Secret`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "ACTIVE24_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "ACTIVE24_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "ACTIVE24_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "ACTIVE24_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/active24`) + case "alidns": // generated from: providers/dns/alidns/alidns.toml ew.writeln(`Configuration for Alibaba Cloud DNS.`) @@ -188,20 +262,45 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Credentials:`) ew.writeln(` - "ALICLOUD_ACCESS_KEY": Access key ID`) - ew.writeln(` - "ALICLOUD_RAM_ROLE": Your instance RAM role (https://www.alibabacloud.com/help/doc-detail/54579.htm)`) + ew.writeln(` - "ALICLOUD_RAM_ROLE": Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance)`) ew.writeln(` - "ALICLOUD_SECRET_KEY": Access Key secret`) ew.writeln(` - "ALICLOUD_SECURITY_TOKEN": STS Security Token (optional)`) ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "ALICLOUD_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "ALICLOUD_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "ALICLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "ALICLOUD_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "ALICLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "ALICLOUD_LINE": Line (Default: default)`) + ew.writeln(` - "ALICLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "ALICLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "ALICLOUD_REGION_ID": Region ID (Default: cn-hangzhou)`) + ew.writeln(` - "ALICLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/alidns`) + case "aliesa": + // generated from: providers/dns/aliesa/aliesa.toml + ew.writeln(`Configuration for AlibabaCloud ESA.`) + ew.writeln(`Code: 'aliesa'`) + ew.writeln(`Since: 'v4.29.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "ALIESA_ACCESS_KEY": Access key ID`) + ew.writeln(` - "ALIESA_RAM_ROLE": Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance)`) + ew.writeln(` - "ALIESA_SECRET_KEY": Access Key secret`) + ew.writeln(` - "ALIESA_SECURITY_TOKEN": STS Security Token (optional)`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "ALIESA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "ALIESA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "ALIESA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "ALIESA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/aliesa`) + case "allinkl": // generated from: providers/dns/allinkl/allinkl.toml ew.writeln(`Configuration for all-inkl.`) @@ -215,13 +314,76 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "ALL_INKL_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "ALL_INKL_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "ALL_INKL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "ALL_INKL_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "ALL_INKL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "ALL_INKL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/allinkl`) + case "alwaysdata": + // generated from: providers/dns/alwaysdata/alwaysdata.toml + ew.writeln(`Configuration for Alwaysdata.`) + ew.writeln(`Code: 'alwaysdata'`) + ew.writeln(`Since: 'v4.31.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "ALWAYSDATA_API_KEY": API Key`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "ALWAYSDATA_ACCOUNT": Account name`) + ew.writeln(` - "ALWAYSDATA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "ALWAYSDATA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "ALWAYSDATA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "ALWAYSDATA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/alwaysdata`) + + case "anexia": + // generated from: providers/dns/anexia/anexia.toml + ew.writeln(`Configuration for Anexia CloudDNS.`) + ew.writeln(`Code: 'anexia'`) + ew.writeln(`Since: 'v4.28.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "ANEXIA_TOKEN": API token for Anexia Engine`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "ANEXIA_API_URL": API endpoint URL (default: https://engine.anexia-it.com)`) + ew.writeln(` - "ANEXIA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "ANEXIA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "ANEXIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) + ew.writeln(` - "ANEXIA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/anexia`) + + case "artfiles": + // generated from: providers/dns/artfiles/artfiles.toml + ew.writeln(`Configuration for ArtFiles.`) + ew.writeln(`Code: 'artfiles'`) + ew.writeln(`Since: 'v4.32.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "ARTFILES_PASSWORD": API password`) + ew.writeln(` - "ARTFILES_USERNAME": API username`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "ARTFILES_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "ARTFILES_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "ARTFILES_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 360)`) + ew.writeln(` - "ARTFILES_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/artfiles`) + case "arvancloud": // generated from: providers/dns/arvancloud/arvancloud.toml ew.writeln(`Configuration for ArvanCloud.`) @@ -234,10 +396,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "ARVANCLOUD_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "ARVANCLOUD_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "ARVANCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "ARVANCLOUD_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "ARVANCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "ARVANCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "ARVANCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "ARVANCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/arvancloud`) @@ -256,9 +418,9 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "AURORA_ENDPOINT": API endpoint URL`) - ew.writeln(` - "AURORA_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "AURORA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "AURORA_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "AURORA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "AURORA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "AURORA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/auroradns`) @@ -278,14 +440,56 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "AUTODNS_CONTEXT": API context (4 for production, 1 for testing. Defaults to 4)`) ew.writeln(` - "AUTODNS_ENDPOINT": API endpoint URL, defaults to https://api.autodns.com/v1/`) - ew.writeln(` - "AUTODNS_HTTP_TIMEOUT": API request timeout, defaults to 30 seconds`) - ew.writeln(` - "AUTODNS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "AUTODNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "AUTODNS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "AUTODNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "AUTODNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "AUTODNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "AUTODNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/autodns`) + case "axelname": + // generated from: providers/dns/axelname/axelname.toml + ew.writeln(`Configuration for Axelname.`) + ew.writeln(`Code: 'axelname'`) + ew.writeln(`Since: 'v4.23.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "AXELNAME_NICKNAME": Account nickname`) + ew.writeln(` - "AXELNAME_TOKEN": API token`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "AXELNAME_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "AXELNAME_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "AXELNAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "AXELNAME_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/axelname`) + + case "azion": + // generated from: providers/dns/azion/azion.toml + ew.writeln(`Configuration for Azion.`) + ew.writeln(`Code: 'azion'`) + ew.writeln(`Since: 'v4.24.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "AZION_PERSONAL_TOKEN": Your Azion personal token.`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "AZION_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "AZION_PAGE_SIZE": The page size for the API request (Default: 50)`) + ew.writeln(` - "AZION_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "AZION_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "AZION_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/azion`) + case "azure": // generated from: providers/dns/azure/azure.toml ew.writeln(`Configuration for Azure (deprecated).`) @@ -305,10 +509,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "AZURE_METADATA_ENDPOINT": Metadata Service endpoint URL`) - ew.writeln(` - "AZURE_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "AZURE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "AZURE_PRIVATE_ZONE": Set to true to use Azure Private DNS Zones and not public`) - ew.writeln(` - "AZURE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "AZURE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "AZURE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "AZURE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln(` - "AZURE_ZONE_NAME": Zone name to use inside Azure DNS service to add the TXT record in`) ew.writeln() @@ -332,18 +536,79 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(` - "AZURE_AUTH_METHOD": Specify which authentication method to use`) ew.writeln(` - "AZURE_AUTH_MSI_TIMEOUT": Managed Identity timeout duration`) ew.writeln(` - "AZURE_ENVIRONMENT": Azure environment, one of: public, usgovernment, and china`) - ew.writeln(` - "AZURE_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "AZURE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "AZURE_PRIVATE_ZONE": Set to true to use Azure Private DNS Zones and not public`) - ew.writeln(` - "AZURE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "AZURE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "AZURE_RESOURCE_GROUP": DNS zone resource group`) ew.writeln(` - "AZURE_SERVICEDISCOVERY_FILTER": Advanced ServiceDiscovery filter using Kusto query condition`) ew.writeln(` - "AZURE_SUBSCRIPTION_ID": DNS zone subscription ID`) - ew.writeln(` - "AZURE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "AZURE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln(` - "AZURE_ZONE_NAME": Zone name to use inside Azure DNS service to add the TXT record in`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/azuredns`) + case "baiducloud": + // generated from: providers/dns/baiducloud/baiducloud.toml + ew.writeln(`Configuration for Baidu Cloud.`) + ew.writeln(`Code: 'baiducloud'`) + ew.writeln(`Since: 'v4.23.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "BAIDUCLOUD_ACCESS_KEY_ID": Access key`) + ew.writeln(` - "BAIDUCLOUD_SECRET_ACCESS_KEY": Secret access key`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "BAIDUCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "BAIDUCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "BAIDUCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/baiducloud`) + + case "beget": + // generated from: providers/dns/beget/beget.toml + ew.writeln(`Configuration for Beget.com.`) + ew.writeln(`Code: 'beget'`) + ew.writeln(`Since: 'v4.27.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "BEGET_PASSWORD": API password`) + ew.writeln(` - "BEGET_USERNAME": API username`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "BEGET_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "BEGET_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`) + ew.writeln(` - "BEGET_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) + ew.writeln(` - "BEGET_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/beget`) + + case "binarylane": + // generated from: providers/dns/binarylane/binarylane.toml + ew.writeln(`Configuration for Binary Lane.`) + ew.writeln(`Code: 'binarylane'`) + ew.writeln(`Since: 'v4.26.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "BINARYLANE_API_TOKEN": API token`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "BINARYLANE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "BINARYLANE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "BINARYLANE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "BINARYLANE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/binarylane`) + case "bindman": // generated from: providers/dns/bindman/bindman.toml ew.writeln(`Configuration for Bindman.`) @@ -356,9 +621,9 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "BINDMAN_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "BINDMAN_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "BINDMAN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "BINDMAN_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) + ew.writeln(` - "BINDMAN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "BINDMAN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/bindman`) @@ -379,15 +644,61 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "BLUECAT_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "BLUECAT_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "BLUECAT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "BLUECAT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "BLUECAT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "BLUECAT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "BLUECAT_SKIP_DEPLOY": Skip deployements`) - ew.writeln(` - "BLUECAT_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "BLUECAT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/bluecat`) + case "bluecatv2": + // generated from: providers/dns/bluecatv2/bluecatv2.toml + ew.writeln(`Configuration for Bluecat v2.`) + ew.writeln(`Code: 'bluecatv2'`) + ew.writeln(`Since: 'v4.32.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "BLUECATV2_CONFIG_NAME": Configuration name`) + ew.writeln(` - "BLUECATV2_PASSWORD": API password`) + ew.writeln(` - "BLUECATV2_USERNAME": API username`) + ew.writeln(` - "BLUECATV2_VIEW_NAME": DNS View Name`) + ew.writeln(` - "BLUECAT_SERVER_URL": The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "BLUECATV2_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "BLUECATV2_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "BLUECATV2_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "BLUECATV2_SKIP_DEPLOY": Skip quick deployements`) + ew.writeln(` - "BLUECATV2_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/bluecatv2`) + + case "bookmyname": + // generated from: providers/dns/bookmyname/bookmyname.toml + ew.writeln(`Configuration for BookMyName.`) + ew.writeln(`Code: 'bookmyname'`) + ew.writeln(`Since: 'v4.23.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "BOOKMYNAME_PASSWORD": Password`) + ew.writeln(` - "BOOKMYNAME_USERNAME": Username`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "BOOKMYNAME_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "BOOKMYNAME_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "BOOKMYNAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "BOOKMYNAME_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/bookmyname`) + case "brandit": // generated from: providers/dns/brandit/brandit.toml ew.writeln(`Configuration for Brandit (deprecated).`) @@ -401,10 +712,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "BRANDIT_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "BRANDIT_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "BRANDIT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "BRANDIT_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "BRANDIT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "BRANDIT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "BRANDIT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`) + ew.writeln(` - "BRANDIT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/brandit`) @@ -421,9 +732,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "BUNNY_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "BUNNY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "BUNNY_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "BUNNY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "BUNNY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "BUNNY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "BUNNY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/bunny`) @@ -441,10 +753,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "CHECKDOMAIN_ENDPOINT": API endpoint URL, defaults to https://api.checkdomain.de`) - ew.writeln(` - "CHECKDOMAIN_HTTP_TIMEOUT": API request timeout, defaults to 30 seconds`) - ew.writeln(` - "CHECKDOMAIN_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "CHECKDOMAIN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "CHECKDOMAIN_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "CHECKDOMAIN_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "CHECKDOMAIN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 300)`) + ew.writeln(` - "CHECKDOMAIN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 7)`) + ew.writeln(` - "CHECKDOMAIN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/checkdomain`) @@ -461,9 +773,9 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "CIVO_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "CIVO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "CIVO_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "CIVO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`) + ew.writeln(` - "CIVO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) + ew.writeln(` - "CIVO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/civo`) @@ -482,10 +794,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "CLOUDDNS_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "CLOUDDNS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "CLOUDDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "CLOUDDNS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "CLOUDDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "CLOUDDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) + ew.writeln(` - "CLOUDDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "CLOUDDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/clouddns`) @@ -509,10 +821,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "CLOUDFLARE_HTTP_TIMEOUT": API request timeout (in seconds)`) - ew.writeln(` - "CLOUDFLARE_POLLING_INTERVAL": Time between DNS propagation check (in seconds)`) - ew.writeln(` - "CLOUDFLARE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation (in seconds)`) - ew.writeln(` - "CLOUDFLARE_TTL": The TTL of the TXT record used for the DNS challenge (in seconds)`) + ew.writeln(` - "CLOUDFLARE_BASE_URL": API base URL (Default: https://api.cloudflare.com/client/v4)`) + ew.writeln(` - "CLOUDFLARE_HTTP_TIMEOUT": API request timeout in seconds (Default: )`) + ew.writeln(` - "CLOUDFLARE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "CLOUDFLARE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "CLOUDFLARE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/cloudflare`) @@ -530,11 +843,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "CLOUDNS_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "CLOUDNS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "CLOUDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "CLOUDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "CLOUDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "CLOUDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 180)`) ew.writeln(` - "CLOUDNS_SUB_AUTH_ID": The API sub user ID`) - ew.writeln(` - "CLOUDNS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "CLOUDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/cloudns`) @@ -553,11 +866,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "CLOUDRU_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "CLOUDRU_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "CLOUDRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "CLOUDRU_SEQUENCE_INTERVAL": Time between sequential requests`) - ew.writeln(` - "CLOUDRU_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "CLOUDRU_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "CLOUDRU_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) + ew.writeln(` - "CLOUDRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) + ew.writeln(` - "CLOUDRU_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 120)`) + ew.writeln(` - "CLOUDRU_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/cloudru`) @@ -575,17 +888,38 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "CLOUDXNS_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "CLOUDXNS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "CLOUDXNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "CLOUDXNS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "CLOUDXNS_HTTP_TIMEOUT": API request timeout in seconds (Default: )`) + ew.writeln(` - "CLOUDXNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: )`) + ew.writeln(` - "CLOUDXNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: )`) + ew.writeln(` - "CLOUDXNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: )`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/cloudxns`) + case "com35": + // generated from: providers/dns/com35/com35.toml + ew.writeln(`Configuration for 35.com/三五互联.`) + ew.writeln(`Code: 'com35'`) + ew.writeln(`Since: 'v4.31.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "COM35_PASSWORD": API password`) + ew.writeln(` - "COM35_USERNAME": Username`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "COM35_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "COM35_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "COM35_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "COM35_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/com35`) + case "conoha": // generated from: providers/dns/conoha/conoha.toml - ew.writeln(`Configuration for ConoHa.`) + ew.writeln(`Configuration for ConoHa v2.`) ew.writeln(`Code: 'conoha'`) ew.writeln(`Since: 'v1.2.0'`) ew.writeln() @@ -597,15 +931,38 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "CONOHA_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "CONOHA_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "CONOHA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "CONOHA_REGION": The region`) - ew.writeln(` - "CONOHA_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "CONOHA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "CONOHA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "CONOHA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "CONOHA_REGION": The region (Default: tyo1)`) + ew.writeln(` - "CONOHA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/conoha`) + case "conohav3": + // generated from: providers/dns/conohav3/conohav3.toml + ew.writeln(`Configuration for ConoHa v3.`) + ew.writeln(`Code: 'conohav3'`) + ew.writeln(`Since: 'v4.24.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "CONOHAV3_API_PASSWORD": The API password`) + ew.writeln(` - "CONOHAV3_API_USER_ID": The API user ID`) + ew.writeln(` - "CONOHAV3_TENANT_ID": Tenant ID`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "CONOHAV3_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "CONOHAV3_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "CONOHAV3_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "CONOHAV3_REGION": The region (Default: c3j1)`) + ew.writeln(` - "CONOHAV3_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/conohav3`) + case "constellix": // generated from: providers/dns/constellix/constellix.toml ew.writeln(`Configuration for Constellix.`) @@ -619,10 +976,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "CONSTELLIX_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "CONSTELLIX_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "CONSTELLIX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "CONSTELLIX_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "CONSTELLIX_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "CONSTELLIX_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "CONSTELLIX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "CONSTELLIX_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/constellix`) @@ -640,11 +997,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "CORENETWORKS_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "CORENETWORKS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "CORENETWORKS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "CORENETWORKS_SEQUENCE_INTERVAL": Time between sequential requests`) - ew.writeln(` - "CORENETWORKS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "CORENETWORKS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "CORENETWORKS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "CORENETWORKS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "CORENETWORKS_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) + ew.writeln(` - "CORENETWORKS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/corenetworks`) @@ -663,16 +1020,56 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "CPANEL_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "CPANEL_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "CPANEL_MODE": use cpanel API or WHM API (Default: cpanel)`) - ew.writeln(` - "CPANEL_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "CPANEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "CPANEL_REGION": The region`) - ew.writeln(` - "CPANEL_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "CPANEL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "CPANEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "CPANEL_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/cpanel`) + case "czechia": + // generated from: providers/dns/czechia/czechia.toml + ew.writeln(`Configuration for Czechia.`) + ew.writeln(`Code: 'czechia'`) + ew.writeln(`Since: 'v4.33.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "CZECHIA_TOKEN": Authorization token`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "CZECHIA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "CZECHIA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "CZECHIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "CZECHIA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/czechia`) + + case "ddnss": + // generated from: providers/dns/ddnss/ddnss.toml + ew.writeln(`Configuration for DDnss (DynDNS Service).`) + ew.writeln(`Code: 'ddnss'`) + ew.writeln(`Since: 'v4.32.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "DDNSS_KEY": Update key`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "DDNSS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "DDNSS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "DDNSS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "DDNSS_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) + ew.writeln(` - "DDNSS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/ddnss`) + case "derak": // generated from: providers/dns/derak/derak.toml ew.writeln(`Configuration for Derak Cloud.`) @@ -685,10 +1082,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "DERAK_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "DERAK_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "DERAK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "DERAK_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "DERAK_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "DERAK_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) + ew.writeln(` - "DERAK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "DERAK_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln(` - "DERAK_WEBSITE_ID": Force the zone/website ID`) ew.writeln() @@ -706,10 +1103,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "DESEC_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "DESEC_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "DESEC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "DESEC_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "DESEC_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "DESEC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`) + ew.writeln(` - "DESEC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "DESEC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/desec`) @@ -734,9 +1131,9 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "DESIGNATE_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "DESIGNATE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "DESIGNATE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "DESIGNATE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "DESIGNATE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`) + ew.writeln(` - "DESIGNATE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)`) ew.writeln(` - "DESIGNATE_ZONE_NAME": The zone name to use in the OpenStack Project to manage TXT records.`) ew.writeln(` - "OS_PROJECT_ID": Project ID`) ew.writeln(` - "OS_TENANT_NAME": Tenant name (deprecated see OS_PROJECT_NAME and OS_PROJECT_ID)`) @@ -757,10 +1154,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "DO_API_URL": The URL of the API`) - ew.writeln(` - "DO_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "DO_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "DO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "DO_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "DO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "DO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) + ew.writeln(` - "DO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "DO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/digitalocean`) @@ -779,15 +1176,35 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "DIRECTADMIN_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "DIRECTADMIN_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "DIRECTADMIN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "DIRECTADMIN_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "DIRECTADMIN_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "DIRECTADMIN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) + ew.writeln(` - "DIRECTADMIN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "DIRECTADMIN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)`) ew.writeln(` - "DIRECTADMIN_ZONE_NAME": Zone name used to add the TXT record`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/directadmin`) + case "dnsexit": + // generated from: providers/dns/dnsexit/dnsexit.toml + ew.writeln(`Configuration for DNSExit.`) + ew.writeln(`Code: 'dnsexit'`) + ew.writeln(`Since: 'v4.32.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "DNSEXIT_API_KEY": API key`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "DNSEXIT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "DNSEXIT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "DNSEXIT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) + ew.writeln(` - "DNSEXIT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/dnsexit`) + case "dnshomede": // generated from: providers/dns/dnshomede/dnshomede.toml ew.writeln(`Configuration for dnsHome.de.`) @@ -800,10 +1217,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "DNSHOMEDE_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "DNSHOMEDE_POLLING_INTERVAL": Time between DNS propagation checks`) - ew.writeln(` - "DNSHOMEDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation; defaults to 300s (5 minutes)`) - ew.writeln(` - "DNSHOMEDE_SEQUENCE_INTERVAL": Time between sequential requests`) + ew.writeln(` - "DNSHOMEDE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "DNSHOMEDE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 1200)`) + ew.writeln(` - "DNSHOMEDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 2)`) + ew.writeln(` - "DNSHOMEDE_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dnshomede`) @@ -821,9 +1238,9 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "DNSIMPLE_BASE_URL": API endpoint URL`) - ew.writeln(` - "DNSIMPLE_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "DNSIMPLE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "DNSIMPLE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "DNSIMPLE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "DNSIMPLE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "DNSIMPLE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dnsimple`) @@ -841,11 +1258,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "DNSMADEEASY_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "DNSMADEEASY_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "DNSMADEEASY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "DNSMADEEASY_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "DNSMADEEASY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "DNSMADEEASY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "DNSMADEEASY_SANDBOX": Activate the sandbox (boolean)`) - ew.writeln(` - "DNSMADEEASY_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "DNSMADEEASY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dnsmadeeasy`) @@ -862,10 +1279,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "DNSPOD_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "DNSPOD_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "DNSPOD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "DNSPOD_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "DNSPOD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "DNSPOD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "DNSPOD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "DNSPOD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dnspod`) @@ -882,11 +1299,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "DODE_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "DODE_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "DODE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "DODE_SEQUENCE_INTERVAL": Time between sequential requests`) - ew.writeln(` - "DODE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "DODE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "DODE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "DODE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "DODE_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dode`) @@ -904,9 +1320,9 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "DOMENESHOP_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "DOMENESHOP_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "DOMENESHOP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "DOMENESHOP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "DOMENESHOP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`) + ew.writeln(` - "DOMENESHOP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/domeneshop`) @@ -923,10 +1339,9 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "DREAMHOST_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "DREAMHOST_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "DREAMHOST_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "DREAMHOST_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "DREAMHOST_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "DREAMHOST_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 60)`) + ew.writeln(` - "DREAMHOST_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dreamhost`) @@ -943,11 +1358,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "DUCKDNS_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "DUCKDNS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "DUCKDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "DUCKDNS_SEQUENCE_INTERVAL": Time between sequential requests`) - ew.writeln(` - "DUCKDNS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "DUCKDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "DUCKDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "DUCKDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "DUCKDNS_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/duckdns`) @@ -966,14 +1380,34 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "DYN_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "DYN_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "DYN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "DYN_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "DYN_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "DYN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "DYN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "DYN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dyn`) + case "dyndnsfree": + // generated from: providers/dns/dyndnsfree/dyndnsfree.toml + ew.writeln(`Configuration for DynDnsFree.de.`) + ew.writeln(`Code: 'dyndnsfree'`) + ew.writeln(`Since: 'v4.23.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "DYNDNSFREE_PASSWORD": Password`) + ew.writeln(` - "DYNDNSFREE_USERNAME": Username`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "DYNDNSFREE_HTTP_TIMEOUT": Request timeout in seconds (Default: 30)`) + ew.writeln(` - "DYNDNSFREE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "DYNDNSFREE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/dyndnsfree`) + case "dynu": // generated from: providers/dns/dynu/dynu.toml ew.writeln(`Configuration for Dynu.`) @@ -986,10 +1420,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "DYNU_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "DYNU_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "DYNU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "DYNU_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "DYNU_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "DYNU_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "DYNU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 180)`) + ew.writeln(` - "DYNU_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dynu`) @@ -1008,15 +1442,35 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "EASYDNS_ENDPOINT": The endpoint URL of the API Server`) - ew.writeln(` - "EASYDNS_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "EASYDNS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "EASYDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "EASYDNS_SEQUENCE_INTERVAL": Time between sequential requests`) - ew.writeln(` - "EASYDNS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "EASYDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "EASYDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "EASYDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "EASYDNS_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) + ew.writeln(` - "EASYDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/easydns`) + case "edgecenter": + // generated from: providers/dns/edgecenter/edgecenter.toml + ew.writeln(`Configuration for EdgeCenter.`) + ew.writeln(`Code: 'edgecenter'`) + ew.writeln(`Since: 'v4.29.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "EDGECENTER_PERMANENT_API_TOKEN": Permanent API token (https://edgecenter.ru/blog/permanent-api-token-explained/)`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "EDGECENTER_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "EDGECENTER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`) + ew.writeln(` - "EDGECENTER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 360)`) + ew.writeln(` - "EDGECENTER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/edgecenter`) + case "edgedns": // generated from: providers/dns/edgedns/edgedns.toml ew.writeln(`Configuration for Akamai EdgeDNS.`) @@ -1034,13 +1488,38 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "AKAMAI_POLLING_INTERVAL": Time between DNS propagation check. Default: 15 seconds`) - ew.writeln(` - "AKAMAI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation. Default: 3 minutes`) - ew.writeln(` - "AKAMAI_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "AKAMAI_ACCOUNT_SWITCH_KEY": Target account ID when the DNS zone and credentials belong to different accounts`) + ew.writeln(` - "AKAMAI_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 15)`) + ew.writeln(` - "AKAMAI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 180)`) + ew.writeln(` - "AKAMAI_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/edgedns`) + case "edgeone": + // generated from: providers/dns/edgeone/edgeone.toml + ew.writeln(`Configuration for Tencent EdgeOne.`) + ew.writeln(`Code: 'edgeone'`) + ew.writeln(`Since: 'v4.26.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "EDGEONE_SECRET_ID": Access key ID`) + ew.writeln(` - "EDGEONE_SECRET_KEY": Access Key secret`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "EDGEONE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "EDGEONE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`) + ew.writeln(` - "EDGEONE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 1200)`) + ew.writeln(` - "EDGEONE_REGION": Region`) + ew.writeln(` - "EDGEONE_SESSION_TOKEN": Access Key token`) + ew.writeln(` - "EDGEONE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) + ew.writeln(` - "EDGEONE_ZONES_MAPPING": Mapping between DNS zones and site IDs. (ex: 'example.org:id1,example.com:id2')`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/edgeone`) + case "efficientip": // generated from: providers/dns/efficientip/efficientip.toml ew.writeln(`Configuration for Efficient IP.`) @@ -1056,11 +1535,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "EFFICIENTIP_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "EFFICIENTIP_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "EFFICIENTIP_INSECURE_SKIP_VERIFY": Whether or not to verify EfficientIP API certificate`) - ew.writeln(` - "EFFICIENTIP_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "EFFICIENTIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "EFFICIENTIP_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "EFFICIENTIP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "EFFICIENTIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "EFFICIENTIP_VIEW_NAME": View name (ex: external)`) ew.writeln() @@ -1078,14 +1556,56 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "EPIK_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "EPIK_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "EPIK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "EPIK_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "EPIK_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "EPIK_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "EPIK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "EPIK_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/epik`) + case "eurodns": + // generated from: providers/dns/eurodns/eurodns.toml + ew.writeln(`Configuration for EuroDNS.`) + ew.writeln(`Code: 'eurodns'`) + ew.writeln(`Since: 'v4.33.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "EURODNS_API_KEY": API key`) + ew.writeln(` - "EURODNS_APP_ID": Application ID`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "EURODNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "EURODNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "EURODNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "EURODNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/eurodns`) + + case "excedo": + // generated from: providers/dns/excedo/excedo.toml + ew.writeln(`Configuration for Excedo.`) + ew.writeln(`Code: 'excedo'`) + ew.writeln(`Since: 'v4.33.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "EXCEDO_API_KEY": API key`) + ew.writeln(` - "EXCEDO_API_URL": API base URL`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "EXCEDO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "EXCEDO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "EXCEDO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) + ew.writeln(` - "EXCEDO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/excedo`) + case "exec": // generated from: providers/dns/exec/exec.toml ew.writeln(`Configuration for External program.`) @@ -1110,14 +1630,37 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "EXOSCALE_ENDPOINT": API endpoint URL`) - ew.writeln(` - "EXOSCALE_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "EXOSCALE_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "EXOSCALE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "EXOSCALE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "EXOSCALE_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) + ew.writeln(` - "EXOSCALE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "EXOSCALE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "EXOSCALE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/exoscale`) + case "f5xc": + // generated from: providers/dns/f5xc/f5xc.toml + ew.writeln(`Configuration for F5 XC.`) + ew.writeln(`Code: 'f5xc'`) + ew.writeln(`Since: 'v4.23.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "F5XC_API_TOKEN": API token`) + ew.writeln(` - "F5XC_GROUP_NAME": Group name`) + ew.writeln(` - "F5XC_TENANT_NAME": XC Tenant shortname`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "F5XC_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "F5XC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "F5XC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "F5XC_SERVER": Server domain (Default: console.ves.volterra.io)`) + ew.writeln(` - "F5XC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/f5xc`) + case "freemyip": // generated from: providers/dns/freemyip/freemyip.toml ew.writeln(`Configuration for freemyip.com.`) @@ -1130,11 +1673,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "FREEMYIP_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "FREEMYIP_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "FREEMYIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "FREEMYIP_SEQUENCE_INTERVAL": Time between sequential requests`) - ew.writeln(` - "FREEMYIP_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "FREEMYIP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "FREEMYIP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "FREEMYIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "FREEMYIP_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) + ew.writeln(` - "FREEMYIP_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/freemyip`) @@ -1151,10 +1694,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "GANDI_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "GANDI_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "GANDI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "GANDI_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "GANDI_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) + ew.writeln(` - "GANDI_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 60)`) + ew.writeln(` - "GANDI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 2400)`) + ew.writeln(` - "GANDI_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/gandi`) @@ -1172,10 +1715,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "GANDIV5_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "GANDIV5_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "GANDIV5_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "GANDIV5_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "GANDIV5_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "GANDIV5_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`) + ew.writeln(` - "GANDIV5_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 1200)`) + ew.writeln(` - "GANDIV5_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/gandiv5`) @@ -1196,9 +1739,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "GCE_ALLOW_PRIVATE_ZONE": Allows requested domain to be in private DNS zone, works only with a private ACME server (by default: false)`) - ew.writeln(` - "GCE_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "GCE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "GCE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "GCE_IMPERSONATE_SERVICE_ACCOUNT": Service account email to impersonate`) + ew.writeln(` - "GCE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) + ew.writeln(` - "GCE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 180)`) + ew.writeln(` - "GCE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln(` - "GCE_ZONE_ID": Allows to skip the automatic detection of the zone`) ew.writeln() @@ -1216,14 +1760,36 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "GCORE_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "GCORE_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "GCORE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "GCORE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "GCORE_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "GCORE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`) + ew.writeln(` - "GCORE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 360)`) + ew.writeln(` - "GCORE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/gcore`) + case "gigahostno": + // generated from: providers/dns/gigahostno/gigahostno.toml + ew.writeln(`Configuration for Gigahost.no.`) + ew.writeln(`Code: 'gigahostno'`) + ew.writeln(`Since: 'v4.29.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "GIGAHOSTNO_PASSWORD": Password`) + ew.writeln(` - "GIGAHOSTNO_USERNAME": Username`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "GIGAHOSTNO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "GIGAHOSTNO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "GIGAHOSTNO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "GIGAHOSTNO_SECRET": TOTP secret`) + ew.writeln(` - "GIGAHOSTNO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/gigahostno`) + case "glesys": // generated from: providers/dns/glesys/glesys.toml ew.writeln(`Configuration for Glesys.`) @@ -1237,10 +1803,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "GLESYS_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "GLESYS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "GLESYS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "GLESYS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "GLESYS_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "GLESYS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`) + ew.writeln(` - "GLESYS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 1200)`) + ew.writeln(` - "GLESYS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/glesys`) @@ -1258,10 +1824,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "GODADDY_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "GODADDY_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "GODADDY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "GODADDY_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "GODADDY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "GODADDY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "GODADDY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "GODADDY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/godaddy`) @@ -1278,13 +1844,35 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "GOOGLE_DOMAINS_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "GOOGLE_DOMAINS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "GOOGLE_DOMAINS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "GOOGLE_DOMAINS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "GOOGLE_DOMAINS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "GOOGLE_DOMAINS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/googledomains`) + case "gravity": + // generated from: providers/dns/gravity/gravity.toml + ew.writeln(`Configuration for Gravity.`) + ew.writeln(`Code: 'gravity'`) + ew.writeln(`Since: 'v4.30.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "GRAVITY_PASSWORD": Password`) + ew.writeln(` - "GRAVITY_SERVER_URL": URL of the server`) + ew.writeln(` - "GRAVITY_USERNAME": Username`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "GRAVITY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "GRAVITY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "GRAVITY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "GRAVITY_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 1)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/gravity`) + case "hetzner": // generated from: providers/dns/hetzner/hetzner.toml ew.writeln(`Configuration for Hetzner.`) @@ -1293,14 +1881,14 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Credentials:`) - ew.writeln(` - "HETZNER_API_KEY": API key`) + ew.writeln(` - "HETZNER_API_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "HETZNER_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "HETZNER_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "HETZNER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "HETZNER_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "HETZNER_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "HETZNER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "HETZNER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "HETZNER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/hetzner`) @@ -1317,15 +1905,55 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "HOSTINGDE_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "HOSTINGDE_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "HOSTINGDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "HOSTINGDE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "HOSTINGDE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "HOSTINGDE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "HOSTINGDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "HOSTINGDE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln(` - "HOSTINGDE_ZONE_NAME": Zone name in ACE format`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/hostingde`) + case "hostinger": + // generated from: providers/dns/hostinger/hostinger.toml + ew.writeln(`Configuration for Hostinger.`) + ew.writeln(`Code: 'hostinger'`) + ew.writeln(`Since: 'v4.27.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "HOSTINGER_API_TOKEN": API Token`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "HOSTINGER_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "HOSTINGER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "HOSTINGER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "HOSTINGER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/hostinger`) + + case "hostingnl": + // generated from: providers/dns/hostingnl/hostingnl.toml + ew.writeln(`Configuration for Hosting.nl.`) + ew.writeln(`Code: 'hostingnl'`) + ew.writeln(`Since: 'v4.30.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "HOSTINGNL_API_KEY": The API key`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "HOSTINGNL_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "HOSTINGNL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "HOSTINGNL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "HOSTINGNL_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/hostingnl`) + case "hosttech": // generated from: providers/dns/hosttech/hosttech.toml ew.writeln(`Configuration for Hosttech.`) @@ -1339,10 +1967,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "HOSTTECH_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "HOSTTECH_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "HOSTTECH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "HOSTTECH_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "HOSTTECH_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "HOSTTECH_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "HOSTTECH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "HOSTTECH_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/hosttech`) @@ -1359,10 +1987,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "HTTPNET_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "HTTPNET_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "HTTPNET_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "HTTPNET_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "HTTPNET_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "HTTPNET_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "HTTPNET_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "HTTPNET_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln(` - "HTTPNET_ZONE_NAME": Zone name in ACE format`) ew.writeln() @@ -1381,10 +2009,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "HTTPREQ_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "HTTPREQ_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "HTTPREQ_PASSWORD": Basic authentication password`) - ew.writeln(` - "HTTPREQ_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "HTTPREQ_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "HTTPREQ_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "HTTPREQ_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "HTTPREQ_USERNAME": Basic authentication username`) ew.writeln() @@ -1404,10 +2032,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "HUAWEICLOUD_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "HUAWEICLOUD_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "HUAWEICLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "HUAWEICLOUD_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "HUAWEICLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "HUAWEICLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "HUAWEICLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "HUAWEICLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/huaweicloud`) @@ -1424,10 +2052,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "HURRICANE_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "HURRICANE_POLLING_INTERVAL": Time between DNS propagation checks`) - ew.writeln(` - "HURRICANE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation; defaults to 300s (5 minutes)`) - ew.writeln(` - "HURRICANE_SEQUENCE_INTERVAL": Time between sequential requests`) + ew.writeln(` - "HURRICANE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "HURRICANE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "HURRICANE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation (Default: 300)`) + ew.writeln(` - "HURRICANE_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/hurricane`) @@ -1441,11 +2069,12 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "HYPERONE_API_URL": Allows to pass custom API Endpoint to be used in the challenge (default https://api.hyperone.com/v2)`) + ew.writeln(` - "HYPERONE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "HYPERONE_LOCATION_ID": Specifies location (region) to be used in API calls. (default pl-waw-1)`) ew.writeln(` - "HYPERONE_PASSPORT_LOCATION": Allows to pass custom passport file location (default ~/.h1/passport.json)`) - ew.writeln(` - "HYPERONE_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "HYPERONE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "HYPERONE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "HYPERONE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 60)`) + ew.writeln(` - "HYPERONE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 2)`) + ew.writeln(` - "HYPERONE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/hyperone`) @@ -1459,14 +2088,14 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Credentials:`) ew.writeln(` - "SOFTLAYER_API_KEY": Classic Infrastructure API key`) - ew.writeln(` - "SOFTLAYER_USERNAME": Username (IBM Cloud is _)`) + ew.writeln(` - "SOFTLAYER_USERNAME": Username (IBM Cloud is {accountID}_{emailAddress})`) ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "SOFTLAYER_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "SOFTLAYER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "SOFTLAYER_TIMEOUT": API request timeout`) - ew.writeln(` - "SOFTLAYER_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "SOFTLAYER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "SOFTLAYER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "SOFTLAYER_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "SOFTLAYER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ibmcloud`) @@ -1485,9 +2114,9 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "IIJ_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "IIJ_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "IIJ_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "IIJ_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`) + ew.writeln(` - "IIJ_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 240)`) + ew.writeln(` - "IIJ_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/iij`) @@ -1506,9 +2135,9 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "IIJ_DPF_API_ENDPOINT": API endpoint URL, defaults to https://api.dns-platform.jp/dpf/v1`) - ew.writeln(` - "IIJ_DPF_POLLING_INTERVAL": Time between DNS propagation check, defaults to 5 second`) - ew.writeln(` - "IIJ_DPF_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation, defaults to 660 second`) - ew.writeln(` - "IIJ_DPF_TTL": The TTL of the TXT record used for the DNS challenge, default to 300`) + ew.writeln(` - "IIJ_DPF_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) + ew.writeln(` - "IIJ_DPF_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 660)`) + ew.writeln(` - "IIJ_DPF_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/iijdpf`) @@ -1527,14 +2156,15 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "INFOBLOX_DNS_VIEW": The view for the TXT records, default: External`) - ew.writeln(` - "INFOBLOX_HTTP_TIMEOUT": HTTP request timeout`) - ew.writeln(` - "INFOBLOX_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "INFOBLOX_PORT": The port for the infoblox grid manager, default: 443`) - ew.writeln(` - "INFOBLOX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "INFOBLOX_SSL_VERIFY": Whether or not to verify the TLS certificate, default: true`) - ew.writeln(` - "INFOBLOX_TTL": The TTL of the TXT record used for the DNS challenge`) - ew.writeln(` - "INFOBLOX_WAPI_VERSION": The version of WAPI being used, default: 2.11`) + ew.writeln(` - "INFOBLOX_CA_CERTIFICATE": The path to the CA certificate (PEM encoded)`) + ew.writeln(` - "INFOBLOX_DNS_VIEW": The view for the TXT records (Default: External)`) + ew.writeln(` - "INFOBLOX_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "INFOBLOX_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "INFOBLOX_PORT": The port for the infoblox grid manager (Default: 443)`) + ew.writeln(` - "INFOBLOX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "INFOBLOX_SSL_VERIFY": Whether or not to verify the TLS certificate (Default: true)`) + ew.writeln(` - "INFOBLOX_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + ew.writeln(` - "INFOBLOX_WAPI_VERSION": The version of WAPI being used (Default: 2.11)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/infoblox`) @@ -1552,10 +2182,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "INFOMANIAK_ENDPOINT": https://api.infomaniak.com`) - ew.writeln(` - "INFOMANIAK_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "INFOMANIAK_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "INFOMANIAK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "INFOMANIAK_TTL": The TTL of the TXT record used for the DNS challenge in seconds`) + ew.writeln(` - "INFOMANIAK_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "INFOMANIAK_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "INFOMANIAK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "INFOMANIAK_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/infomaniak`) @@ -1573,10 +2203,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "INTERNET_BS_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "INTERNET_BS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "INTERNET_BS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "INTERNET_BS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "INTERNET_BS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "INTERNET_BS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "INTERNET_BS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "INTERNET_BS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/internetbs`) @@ -1594,11 +2224,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "INWX_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "INWX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation (default 360s)`) + ew.writeln(` - "INWX_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "INWX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 360)`) ew.writeln(` - "INWX_SANDBOX": Activate the sandbox (boolean)`) ew.writeln(` - "INWX_SHARED_SECRET": shared secret related to 2FA`) - ew.writeln(` - "INWX_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "INWX_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/inwx`) @@ -1615,14 +2245,34 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "IONOS_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "IONOS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "IONOS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "IONOS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "IONOS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "IONOS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "IONOS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 900)`) + ew.writeln(` - "IONOS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ionos`) + case "ionoscloud": + // generated from: providers/dns/ionoscloud/ionoscloud.toml + ew.writeln(`Configuration for Ionos Cloud.`) + ew.writeln(`Code: 'ionoscloud'`) + ew.writeln(`Since: 'v4.30.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "IONOSCLOUD_API_TOKEN": API token`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "IONOSCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "IONOSCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "IONOSCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "IONOSCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/ionoscloud`) + case "ipv64": // generated from: providers/dns/ipv64/ipv64.toml ew.writeln(`Configuration for IPv64.`) @@ -1635,17 +2285,60 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "IPV64_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "IPV64_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "IPV64_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "IPV64_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "IPV64_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "IPV64_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "IPV64_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ipv64`) + case "ispconfig": + // generated from: providers/dns/ispconfig/ispconfig.toml + ew.writeln(`Configuration for ISPConfig 3.`) + ew.writeln(`Code: 'ispconfig'`) + ew.writeln(`Since: 'v4.31.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "ISPCONFIG_PASSWORD": Password`) + ew.writeln(` - "ISPCONFIG_SERVER_URL": Server URL`) + ew.writeln(` - "ISPCONFIG_USERNAME": Username`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "ISPCONFIG_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "ISPCONFIG_INSECURE_SKIP_VERIFY": Whether to verify the API certificate`) + ew.writeln(` - "ISPCONFIG_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "ISPCONFIG_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "ISPCONFIG_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/ispconfig`) + + case "ispconfigddns": + // generated from: providers/dns/ispconfigddns/ispconfigddns.toml + ew.writeln(`Configuration for ISPConfig 3 - Dynamic DNS (DDNS) Module.`) + ew.writeln(`Code: 'ispconfigddns'`) + ew.writeln(`Since: 'v4.31.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "ISPCONFIG_DDNS_SERVER_URL": API server URL (ex: https://panel.example.com:8080)`) + ew.writeln(` - "ISPCONFIG_DDNS_TOKEN": DDNS API token`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "ISPCONFIG_DDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "ISPCONFIG_DDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "ISPCONFIG_DDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "ISPCONFIG_DDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/ispconfigddns`) + case "iwantmyname": // generated from: providers/dns/iwantmyname/iwantmyname.toml - ew.writeln(`Configuration for iwantmyname.`) + ew.writeln(`Configuration for iwantmyname (Deprecated).`) ew.writeln(`Code: 'iwantmyname'`) ew.writeln(`Since: 'v4.7.0'`) ew.writeln() @@ -1656,14 +2349,36 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "IWANTMYNAME_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "IWANTMYNAME_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "IWANTMYNAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "IWANTMYNAME_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "IWANTMYNAME_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "IWANTMYNAME_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "IWANTMYNAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "IWANTMYNAME_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/iwantmyname`) + case "jdcloud": + // generated from: providers/dns/jdcloud/jdcloud.toml + ew.writeln(`Configuration for JD Cloud.`) + ew.writeln(`Code: 'jdcloud'`) + ew.writeln(`Since: 'v4.31.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "JDCLOUD_ACCESS_KEY_ID": Access key ID`) + ew.writeln(` - "JDCLOUD_ACCESS_KEY_SECRET": Access key secret`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "JDCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "JDCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "JDCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "JDCLOUD_REGION_ID": Region ID (Default: cn-north-1)`) + ew.writeln(` - "JDCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/jdcloud`) + case "joker": // generated from: providers/dns/joker/joker.toml ew.writeln(`Configuration for Joker.`) @@ -1679,15 +2394,56 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "JOKER_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "JOKER_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "JOKER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "JOKER_SEQUENCE_INTERVAL": Time between sequential requests (only with 'SVC' mode)`) - ew.writeln(` - "JOKER_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "JOKER_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) + ew.writeln(` - "JOKER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "JOKER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "JOKER_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60), only with 'SVC' mode`) + ew.writeln(` - "JOKER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/joker`) + case "keyhelp": + // generated from: providers/dns/keyhelp/keyhelp.toml + ew.writeln(`Configuration for KeyHelp.`) + ew.writeln(`Code: 'keyhelp'`) + ew.writeln(`Since: 'v4.26.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "KEYHELP_API_KEY": API key`) + ew.writeln(` - "KEYHELP_BASE_URL": Server URL`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "KEYHELP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "KEYHELP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "KEYHELP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "KEYHELP_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/keyhelp`) + + case "leaseweb": + // generated from: providers/dns/leaseweb/leaseweb.toml + ew.writeln(`Configuration for Leaseweb.`) + ew.writeln(`Code: 'leaseweb'`) + ew.writeln(`Since: 'v4.32.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "LEASEWEB_API_KEY": API key`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "LEASEWEB_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "LEASEWEB_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "LEASEWEB_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "LEASEWEB_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/leaseweb`) + case "liara": // generated from: providers/dns/liara/liara.toml ew.writeln(`Configuration for Liara.`) @@ -1700,10 +2456,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "LIARA_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "LIARA_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "LIARA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "LIARA_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "LIARA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "LIARA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "LIARA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "LIARA_TEAM_ID": The team ID to access services in a team`) + ew.writeln(` - "LIARA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/liara`) @@ -1723,8 +2480,8 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "AWS_SHARED_CREDENTIALS_FILE": Managed by the AWS client. Shared credentials file.`) - ew.writeln(` - "LIGHTSAIL_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "LIGHTSAIL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "LIGHTSAIL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "LIGHTSAIL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/lightsail`) @@ -1741,11 +2498,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "LIMACITY_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "LIMACITY_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "LIMACITY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "LIMACITY_SEQUENCE_INTERVAL": Time between sequential requests`) - ew.writeln(` - "LIMACITY_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "LIMACITY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "LIMACITY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 80)`) + ew.writeln(` - "LIMACITY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 480)`) + ew.writeln(` - "LIMACITY_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 90)`) + ew.writeln(` - "LIMACITY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/limacity`) @@ -1762,10 +2519,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "LINODE_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "LINODE_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "LINODE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "LINODE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "LINODE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "LINODE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 15)`) + ew.writeln(` - "LINODE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "LINODE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/linode`) @@ -1783,10 +2540,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "LWAPI_HTTP_TIMEOUT": Maximum waiting time for the DNS records to be created (not verified)`) - ew.writeln(` - "LWAPI_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "LWAPI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "LWAPI_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "LWAPI_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) + ew.writeln(` - "LWAPI_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "LWAPI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "LWAPI_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln(` - "LWAPI_URL": Liquid Web API endpoint`) ew.writeln(` - "LWAPI_ZONE": DNS Zone`) @@ -1807,10 +2564,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "LOOPIA_API_URL": API endpoint. Ex: https://api.loopia.se/RPCSERV or https://api.loopia.rs/RPCSERV`) - ew.writeln(` - "LOOPIA_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "LOOPIA_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "LOOPIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "LOOPIA_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "LOOPIA_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) + ew.writeln(` - "LOOPIA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2400)`) + ew.writeln(` - "LOOPIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "LOOPIA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/loopia`) @@ -1828,10 +2585,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "LUADNS_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "LUADNS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "LUADNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "LUADNS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "LUADNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "LUADNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "LUADNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "LUADNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/luadns`) @@ -1850,12 +2607,43 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "MAILINABOX_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "MAILINABOX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "MAILINABOX_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "MAILINABOX_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`) + ew.writeln(` - "MAILINABOX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/mailinabox`) + case "manageengine": + // generated from: providers/dns/manageengine/manageengine.toml + ew.writeln(`Configuration for ManageEngine CloudDNS.`) + ew.writeln(`Code: 'manageengine'`) + ew.writeln(`Since: 'v4.21.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "MANAGEENGINE_CLIENT_ID": Client ID`) + ew.writeln(` - "MANAGEENGINE_CLIENT_SECRET": Client Secret`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "MANAGEENGINE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "MANAGEENGINE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "MANAGEENGINE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/manageengine`) + + case "manual": + // generated from: providers/dns/manual/manual.toml + ew.writeln(`Configuration for Manual.`) + ew.writeln(`Code: 'manual'`) + ew.writeln(`Since: 'v0.3.0'`) + ew.writeln() + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/manual`) + case "metaname": // generated from: providers/dns/metaname/metaname.toml ew.writeln(`Configuration for Metaname.`) @@ -1869,13 +2657,33 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "METANAME_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "METANAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "METANAME_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "METANAME_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "METANAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "METANAME_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/metaname`) + case "metaregistrar": + // generated from: providers/dns/metaregistrar/metaregistrar.toml + ew.writeln(`Configuration for Metaregistrar.`) + ew.writeln(`Code: 'metaregistrar'`) + ew.writeln(`Since: 'v4.23.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "METAREGISTRAR_API_TOKEN": The API token`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "METAREGISTRAR_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "METAREGISTRAR_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "METAREGISTRAR_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "METAREGISTRAR_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/metaregistrar`) + case "mijnhost": // generated from: providers/dns/mijnhost/mijnhost.toml ew.writeln(`Configuration for mijn.host.`) @@ -1888,11 +2696,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "MIJNHOST_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "MIJNHOST_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "MIJNHOST_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "MIJNHOST_SEQUENCE_INTERVAL": Time between sequential requests`) - ew.writeln(` - "MIJNHOST_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "MIJNHOST_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "MIJNHOST_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "MIJNHOST_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "MIJNHOST_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) + ew.writeln(` - "MIJNHOST_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/mijnhost`) @@ -1909,15 +2717,36 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "MITTWALD_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "MITTWALD_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "MITTWALD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "MITTWALD_SEQUENCE_INTERVAL": Time between sequential requests`) - ew.writeln(` - "MITTWALD_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "MITTWALD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "MITTWALD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "MITTWALD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "MITTWALD_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 120)`) + ew.writeln(` - "MITTWALD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/mittwald`) + case "myaddr": + // generated from: providers/dns/myaddr/myaddr.toml + ew.writeln(`Configuration for myaddr.{tools,dev,io}.`) + ew.writeln(`Code: 'myaddr'`) + ew.writeln(`Since: 'v4.22.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "MYADDR_PRIVATE_KEYS_MAPPING": Mapping between subdomains and private keys. The format is: ':,:,:'`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "MYADDR_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "MYADDR_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "MYADDR_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "MYADDR_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 2)`) + ew.writeln(` - "MYADDR_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/myaddr`) + case "mydnsjp": // generated from: providers/dns/mydnsjp/mydnsjp.toml ew.writeln(`Configuration for MyDNS.jp.`) @@ -1931,10 +2760,9 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "MYDNSJP_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "MYDNSJP_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "MYDNSJP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "MYDNSJP_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "MYDNSJP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "MYDNSJP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "MYDNSJP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/mydnsjp`) @@ -1954,10 +2782,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "MYTHICBEASTS_API_ENDPOINT": The endpoint for the API (must implement v2)`) ew.writeln(` - "MYTHICBEASTS_AUTH_API_ENDPOINT": The endpoint for Mythic Beasts' Authentication`) - ew.writeln(` - "MYTHICBEASTS_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "MYTHICBEASTS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "MYTHICBEASTS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "MYTHICBEASTS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "MYTHICBEASTS_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "MYTHICBEASTS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "MYTHICBEASTS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "MYTHICBEASTS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/mythicbeasts`) @@ -1975,11 +2803,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "NAMECHEAP_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "NAMECHEAP_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "NAMECHEAP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "NAMECHEAP_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) + ew.writeln(` - "NAMECHEAP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 15)`) + ew.writeln(` - "NAMECHEAP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 3600)`) ew.writeln(` - "NAMECHEAP_SANDBOX": Activate the sandbox (boolean)`) - ew.writeln(` - "NAMECHEAP_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "NAMECHEAP_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/namecheap`) @@ -1997,10 +2825,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "NAMECOM_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "NAMECOM_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "NAMECOM_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "NAMECOM_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "NAMECOM_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "NAMECOM_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`) + ew.writeln(` - "NAMECOM_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 900)`) + ew.writeln(` - "NAMECOM_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/namedotcom`) @@ -2017,13 +2845,37 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "NAMESILO_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "NAMESILO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation, it is better to set larger than 15m`) - ew.writeln(` - "NAMESILO_TTL": The TTL of the TXT record used for the DNS challenge, should be in [3600, 2592000]`) + ew.writeln(` - "NAMESILO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "NAMESILO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60), it is better to set larger than 15 minutes`) + ew.writeln(` - "NAMESILO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600), should be in [3600, 2592000]`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/namesilo`) + case "namesurfer": + // generated from: providers/dns/namesurfer/namesurfer.toml + ew.writeln(`Configuration for FusionLayer NameSurfer.`) + ew.writeln(`Code: 'namesurfer'`) + ew.writeln(`Since: 'v4.32.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "NAMESURFER_API_KEY": API key name`) + ew.writeln(` - "NAMESURFER_API_SECRET": API secret`) + ew.writeln(` - "NAMESURFER_BASE_URL": The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10)`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "NAMESURFER_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "NAMESURFER_INSECURE_SKIP_VERIFY": Whether to verify the API certificate`) + ew.writeln(` - "NAMESURFER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "NAMESURFER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "NAMESURFER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) + ew.writeln(` - "NAMESURFER_VIEW": DNS view name (optional, default: empty string)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/namesurfer`) + case "nearlyfreespeech": // generated from: providers/dns/nearlyfreespeech/nearlyfreespeech.toml ew.writeln(`Configuration for NearlyFreeSpeech.NET.`) @@ -2037,15 +2889,35 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "NEARLYFREESPEECH_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "NEARLYFREESPEECH_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "NEARLYFREESPEECH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "NEARLYFREESPEECH_SEQUENCE_INTERVAL": Time between sequential requests`) - ew.writeln(` - "NEARLYFREESPEECH_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "NEARLYFREESPEECH_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "NEARLYFREESPEECH_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "NEARLYFREESPEECH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "NEARLYFREESPEECH_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) + ew.writeln(` - "NEARLYFREESPEECH_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/nearlyfreespeech`) + case "neodigit": + // generated from: providers/dns/neodigit/neodigit.toml + ew.writeln(`Configuration for Neodigit.`) + ew.writeln(`Code: 'neodigit'`) + ew.writeln(`Since: 'v4.30.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "NEODIGIT_TOKEN": API token`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "NEODIGIT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "NEODIGIT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "NEODIGIT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) + ew.writeln(` - "NEODIGIT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/neodigit`) + case "netcup": // generated from: providers/dns/netcup/netcup.toml ew.writeln(`Configuration for Netcup.`) @@ -2060,10 +2932,9 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "NETCUP_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "NETCUP_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "NETCUP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "NETCUP_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "NETCUP_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "NETCUP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`) + ew.writeln(` - "NETCUP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 900)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/netcup`) @@ -2080,10 +2951,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "NETLIFY_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "NETLIFY_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "NETLIFY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "NETLIFY_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "NETLIFY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "NETLIFY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "NETLIFY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "NETLIFY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/netlify`) @@ -2103,16 +2974,39 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "NICMANAGER_API_MODE": mode: 'anycast' or 'zone' (default: 'anycast')`) + ew.writeln(` - "NICMANAGER_API_MODE": mode: 'anycast' or 'zones' (for FreeDNS) (default: 'anycast')`) ew.writeln(` - "NICMANAGER_API_OTP": TOTP Secret (optional)`) - ew.writeln(` - "NICMANAGER_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "NICMANAGER_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "NICMANAGER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "NICMANAGER_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "NICMANAGER_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "NICMANAGER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "NICMANAGER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) + ew.writeln(` - "NICMANAGER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 900)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/nicmanager`) + case "nicru": + // generated from: providers/dns/nicru/nicru.toml + ew.writeln(`Configuration for RU CENTER.`) + ew.writeln(`Code: 'nicru'`) + ew.writeln(`Since: 'v4.24.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "NICRU_PASSWORD": Password for an account in RU CENTER`) + ew.writeln(` - "NICRU_SECRET": Secret for application in DNS-hosting RU CENTER`) + ew.writeln(` - "NICRU_SERVICE_ID": Service ID for application in DNS-hosting RU CENTER`) + ew.writeln(` - "NICRU_SERVICE_NAME": Service Name for DNS-hosting RU CENTER`) + ew.writeln(` - "NICRU_USER": Agreement for an account in RU CENTER`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "NICRU_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 60)`) + ew.writeln(` - "NICRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`) + ew.writeln(` - "NICRU_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/nicru`) + case "nifcloud": // generated from: providers/dns/nifcloud/nifcloud.toml ew.writeln(`Configuration for NIFCloud.`) @@ -2126,10 +3020,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "NIFCLOUD_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "NIFCLOUD_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "NIFCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "NIFCLOUD_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "NIFCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "NIFCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "NIFCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "NIFCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/nifcloud`) @@ -2146,10 +3040,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "NJALLA_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "NJALLA_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "NJALLA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "NJALLA_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "NJALLA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "NJALLA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "NJALLA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "NJALLA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/njalla`) @@ -2166,10 +3060,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "NODION_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "NODION_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "NODION_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "NODION_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "NODION_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "NODION_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "NODION_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "NODION_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/nodion`) @@ -2186,14 +3080,34 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "NS1_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "NS1_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "NS1_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "NS1_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "NS1_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "NS1_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "NS1_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "NS1_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ns1`) + case "octenium": + // generated from: providers/dns/octenium/octenium.toml + ew.writeln(`Configuration for Octenium.`) + ew.writeln(`Code: 'octenium'`) + ew.writeln(`Since: 'v4.27.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "OCTENIUM_API_KEY": API key`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "OCTENIUM_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "OCTENIUM_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "OCTENIUM_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "OCTENIUM_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/octenium`) + case "oraclecloud": // generated from: providers/dns/oraclecloud/oraclecloud.toml ew.writeln(`Configuration for Oracle Cloud.`) @@ -2203,18 +3117,25 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Credentials:`) ew.writeln(` - "OCI_COMPARTMENT_OCID": Compartment OCID`) - ew.writeln(` - "OCI_PRIVKEY_FILE": Private key file`) - ew.writeln(` - "OCI_PRIVKEY_PASS": Private key password`) - ew.writeln(` - "OCI_PUBKEY_FINGERPRINT": Public key fingerprint`) - ew.writeln(` - "OCI_REGION": Region`) - ew.writeln(` - "OCI_TENANCY_OCID": Tenancy OCID`) - ew.writeln(` - "OCI_USER_OCID": User OCID`) + ew.writeln(` - "OCI_FINGERPRINT": Public key fingerprint (ignored if 'OCI_AUTH_TYPE=instance_principal')`) + ew.writeln(` - "OCI_PRIVATE_KEY_PASSWORD": Private key password (ignored if 'OCI_AUTH_TYPE=instance_principal')`) + ew.writeln(` - "OCI_PRIVATE_KEY_PATH": Private key file (ignored if 'OCI_AUTH_TYPE=instance_principal')`) + ew.writeln(` - "OCI_REGION": Region (it can be empty if 'OCI_AUTH_TYPE=instance_principal').`) + ew.writeln(` - "OCI_TENANCY_OCID": Tenancy OCID (ignored if 'OCI_AUTH_TYPE=instance_principal')`) + ew.writeln(` - "OCI_USER_OCID": User OCID (ignored if 'OCI_AUTH_TYPE=instance_principal')`) ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "OCI_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "OCI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "OCI_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "OCI_AUTH_TYPE": Authorization type. Possible values: 'instance_principal', '' (Default: '')`) + ew.writeln(` - "OCI_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) + ew.writeln(` - "OCI_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "OCI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "OCI_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + ew.writeln(` - "TF_VAR_fingerprint": Alias on 'OCI_FINGERPRINT'`) + ew.writeln(` - "TF_VAR_private_key_path": Alias on 'OCI_PRIVATE_KEY_PATH'`) + ew.writeln(` - "TF_VAR_region": Alias on 'OCI_REGION'`) + ew.writeln(` - "TF_VAR_tenancy_ocid": Alias on 'OCI_TENANCY_OCID'`) + ew.writeln(` - "TF_VAR_user_ocid": Alias on 'OCI_USER_OCID'`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/oraclecloud`) @@ -2228,18 +3149,19 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Credentials:`) ew.writeln(` - "OTC_DOMAIN_NAME": Domain name`) - ew.writeln(` - "OTC_IDENTITY_ENDPOINT": Identity endpoint URL`) ew.writeln(` - "OTC_PASSWORD": Password`) ew.writeln(` - "OTC_PROJECT_NAME": Project name`) ew.writeln(` - "OTC_USER_NAME": User name`) ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "OTC_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "OTC_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "OTC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "OTC_SEQUENCE_INTERVAL": Time between sequential requests`) - ew.writeln(` - "OTC_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "OTC_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "OTC_IDENTITY_ENDPOINT": Identity endpoint URL (default: https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens)`) + ew.writeln(` - "OTC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "OTC_PRIVATE_ZONE": Set to true to use private zones only (default: use public zones only)`) + ew.writeln(` - "OTC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "OTC_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) + ew.writeln(` - "OTC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/otc`) @@ -2262,10 +3184,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "OVH_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "OVH_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "OVH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "OVH_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "OVH_HTTP_TIMEOUT": API request timeout in seconds (Default: 180)`) + ew.writeln(` - "OVH_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "OVH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "OVH_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ovh`) @@ -2284,11 +3206,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "PDNS_API_VERSION": Skip API version autodetection and use the provided version number.`) - ew.writeln(` - "PDNS_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "PDNS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "PDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "PDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "PDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "PDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "PDNS_SERVER_NAME": Name of the server in the URL, 'localhost' by default`) - ew.writeln(` - "PDNS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "PDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/pdns`) @@ -2307,10 +3229,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "PLESK_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "PLESK_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "PLESK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "PLESK_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "PLESK_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "PLESK_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "PLESK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "PLESK_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/plesk`) @@ -2328,10 +3250,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "PORKBUN_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "PORKBUN_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "PORKBUN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "PORKBUN_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "PORKBUN_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "PORKBUN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "PORKBUN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`) + ew.writeln(` - "PORKBUN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/porkbun`) @@ -2349,14 +3271,34 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "RACKSPACE_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "RACKSPACE_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "RACKSPACE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "RACKSPACE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "RACKSPACE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "RACKSPACE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 3)`) + ew.writeln(` - "RACKSPACE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "RACKSPACE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/rackspace`) + case "rainyun": + // generated from: providers/dns/rainyun/rainyun.toml + ew.writeln(`Configuration for Rain Yun/雨云.`) + ew.writeln(`Code: 'rainyun'`) + ew.writeln(`Since: 'v4.21.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "RAINYUN_API_KEY": API key`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "RAINYUN_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "RAINYUN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "RAINYUN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "RAINYUN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/rainyun`) + case "rcodezero": // generated from: providers/dns/rcodezero/rcodezero.toml ew.writeln(`Configuration for RcodeZero.`) @@ -2369,10 +3311,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "RCODEZERO_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "RCODEZERO_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "RCODEZERO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "RCODEZERO_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "RCODEZERO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "RCODEZERO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "RCODEZERO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 240)`) + ew.writeln(` - "RCODEZERO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/rcodezero`) @@ -2389,10 +3331,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "REGFISH_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "REGFISH_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "REGFISH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "REGFISH_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "REGFISH_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "REGFISH_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "REGFISH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "REGFISH_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/regfish`) @@ -2410,12 +3352,12 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "REGRU_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "REGRU_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "REGRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "REGRU_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "REGRU_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "REGRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "REGRU_TLS_CERT": authentication certificate`) ew.writeln(` - "REGRU_TLS_KEY": authentication private key`) - ew.writeln(` - "REGRU_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "REGRU_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/regru`) @@ -2435,12 +3377,12 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "RFC2136_DNS_TIMEOUT": API request timeout`) - ew.writeln(` - "RFC2136_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "RFC2136_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "RFC2136_SEQUENCE_INTERVAL": Time between sequential requests`) + ew.writeln(` - "RFC2136_DNS_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "RFC2136_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "RFC2136_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "RFC2136_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln(` - "RFC2136_TSIG_FILE": Path to a key file generated by tsig-keygen`) - ew.writeln(` - "RFC2136_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "RFC2136_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/rfc2136`) @@ -2457,10 +3399,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "RIMUHOSTING_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "RIMUHOSTING_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "RIMUHOSTING_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "RIMUHOSTING_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "RIMUHOSTING_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "RIMUHOSTING_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "RIMUHOSTING_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "RIMUHOSTING_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/rimuhosting`) @@ -2486,17 +3428,18 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "AWS_MAX_RETRIES": The number of maximum returns the service will use to make an individual API request`) - ew.writeln(` - "AWS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "AWS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "AWS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`) + ew.writeln(` - "AWS_PRIVATE_ZONE": Set to true to use private zones only (default: use public zones only)`) + ew.writeln(` - "AWS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "AWS_SHARED_CREDENTIALS_FILE": Managed by the AWS client. Shared credentials file.`) - ew.writeln(` - "AWS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "AWS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/route53`) case "safedns": // generated from: providers/dns/safedns/safedns.toml - ew.writeln(`Configuration for UKFast SafeDNS.`) + ew.writeln(`Configuration for ANS SafeDNS.`) ew.writeln(`Code: 'safedns'`) ew.writeln(`Since: 'v4.6.0'`) ew.writeln() @@ -2506,10 +3449,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "SAFEDNS_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "SAFEDNS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "SAFEDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "SAFEDNS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "SAFEDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "SAFEDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "SAFEDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "SAFEDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/safedns`) @@ -2527,10 +3470,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "SAKURACLOUD_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "SAKURACLOUD_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "SAKURACLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "SAKURACLOUD_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "SAKURACLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "SAKURACLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "SAKURACLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "SAKURACLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/sakuracloud`) @@ -2549,9 +3492,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "SCW_ACCESS_KEY": Access key`) - ew.writeln(` - "SCW_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "SCW_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "SCW_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "SCW_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "SCW_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "SCW_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "SCW_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/scaleway`) @@ -2569,10 +3513,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "SELECTEL_BASE_URL": API endpoint URL`) - ew.writeln(` - "SELECTEL_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "SELECTEL_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "SELECTEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "SELECTEL_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "SELECTEL_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "SELECTEL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "SELECTEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "SELECTEL_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/selectel`) @@ -2592,11 +3536,14 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) + ew.writeln(` - "SELECTELV2_AUTH_REGION": Location for auth endpoint like ResellAPI or Keystone (default: 'ru-1')`) + ew.writeln(` - "SELECTELV2_AUTH_URL": Identity endpoint (defaul: 'https://cloud.api.selcloud.ru/identity/v3/')`) ew.writeln(` - "SELECTELV2_BASE_URL": API endpoint URL`) - ew.writeln(` - "SELECTELV2_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "SELECTELV2_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "SELECTELV2_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "SELECTELV2_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "SELECTELV2_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "SELECTELV2_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) + ew.writeln(` - "SELECTELV2_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "SELECTELV2_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) + ew.writeln(` - "SELECTELV2_USER_DOMAIN_NAME": To specify the domain name (account ID) where the user is located. (default: SELECTELV2_ACCOUNT_ID)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/selectelv2`) @@ -2615,10 +3562,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "SELFHOSTDE_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "SELFHOSTDE_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "SELFHOSTDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "SELFHOSTDE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "SELFHOSTDE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "SELFHOSTDE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`) + ew.writeln(` - "SELFHOSTDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 240)`) + ew.writeln(` - "SELFHOSTDE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/selfhostde`) @@ -2636,10 +3583,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "SERVERCOW_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "SERVERCOW_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "SERVERCOW_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "SERVERCOW_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "SERVERCOW_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "SERVERCOW_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "SERVERCOW_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "SERVERCOW_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/servercow`) @@ -2657,10 +3604,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "SHELLRENT_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "SHELLRENT_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "SHELLRENT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "SHELLRENT_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "SHELLRENT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "SHELLRENT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "SHELLRENT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) + ew.writeln(` - "SHELLRENT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/shellrent`) @@ -2678,10 +3625,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "SIMPLY_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "SIMPLY_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "SIMPLY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "SIMPLY_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "SIMPLY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "SIMPLY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "SIMPLY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) + ew.writeln(` - "SIMPLY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/simply`) @@ -2699,15 +3646,36 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "SONIC_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "SONIC_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "SONIC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "SONIC_SEQUENCE_INTERVAL": Time between sequential requests`) - ew.writeln(` - "SONIC_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "SONIC_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "SONIC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "SONIC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "SONIC_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) + ew.writeln(` - "SONIC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/sonic`) + case "spaceship": + // generated from: providers/dns/spaceship/spaceship.toml + ew.writeln(`Configuration for Spaceship.`) + ew.writeln(`Code: 'spaceship'`) + ew.writeln(`Since: 'v4.22.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "SPACESHIP_API_KEY": API key`) + ew.writeln(` - "SPACESHIP_API_SECRET": API secret`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "SPACESHIP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "SPACESHIP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "SPACESHIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "SPACESHIP_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/spaceship`) + case "stackpath": // generated from: providers/dns/stackpath/stackpath.toml ew.writeln(`Configuration for Stackpath.`) @@ -2722,13 +3690,33 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "STACKPATH_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "STACKPATH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "STACKPATH_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "STACKPATH_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "STACKPATH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "STACKPATH_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/stackpath`) + case "syse": + // generated from: providers/dns/syse/syse.toml + ew.writeln(`Configuration for Syse.`) + ew.writeln(`Code: 'syse'`) + ew.writeln(`Since: 'v4.30.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "SYSE_CREDENTIALS": Comma-separated list of 'zone:password' credential pairs`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "SYSE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "SYSE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "SYSE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 1200)`) + ew.writeln(` - "SYSE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/syse`) + case "technitium": // generated from: providers/dns/technitium/technitium.toml ew.writeln(`Configuration for Technitium.`) @@ -2742,10 +3730,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "TECHNITIUM_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "TECHNITIUM_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "TECHNITIUM_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "TECHNITIUM_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "TECHNITIUM_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "TECHNITIUM_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "TECHNITIUM_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "TECHNITIUM_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/technitium`) @@ -2763,12 +3751,12 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "TENCENTCLOUD_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "TENCENTCLOUD_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "TENCENTCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "TENCENTCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "TENCENTCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "TENCENTCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "TENCENTCLOUD_REGION": Region`) ew.writeln(` - "TENCENTCLOUD_SESSION_TOKEN": Access Key token`) - ew.writeln(` - "TENCENTCLOUD_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "TENCENTCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/tencentcloud`) @@ -2785,13 +3773,34 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "TIMEWEBCLOUD_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "TIMEWEBCLOUD_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "TIMEWEBCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "TIMEWEBCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "TIMEWEBCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "TIMEWEBCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/timewebcloud`) + case "todaynic": + // generated from: providers/dns/todaynic/todaynic.toml + ew.writeln(`Configuration for TodayNIC/时代互联.`) + ew.writeln(`Code: 'todaynic'`) + ew.writeln(`Since: 'v4.32.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "TODAYNIC_API_KEY": API key`) + ew.writeln(` - "TODAYNIC_AUTH_USER_ID": account ID`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "TODAYNIC_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "TODAYNIC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "TODAYNIC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "TODAYNIC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/todaynic`) + case "transip": // generated from: providers/dns/transip/transip.toml ew.writeln(`Configuration for TransIP.`) @@ -2805,9 +3814,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "TRANSIP_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "TRANSIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "TRANSIP_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "TRANSIP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "TRANSIP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "TRANSIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`) + ew.writeln(` - "TRANSIP_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/transip`) @@ -2826,13 +3836,33 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "ULTRADNS_ENDPOINT": API endpoint URL, defaults to https://api.ultradns.com/`) - ew.writeln(` - "ULTRADNS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "ULTRADNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "ULTRADNS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "ULTRADNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`) + ew.writeln(` - "ULTRADNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "ULTRADNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ultradns`) + case "uniteddomains": + // generated from: providers/dns/uniteddomains/uniteddomains.toml + ew.writeln(`Configuration for United-Domains.`) + ew.writeln(`Code: 'uniteddomains'`) + ew.writeln(`Since: 'v4.29.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "UNITEDDOMAINS_API_KEY": API key '.' https://www.united-domains.de/help/faq-article/getting-started-with-the-united-domains-dns-api/`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "UNITEDDOMAINS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "UNITEDDOMAINS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "UNITEDDOMAINS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 900)`) + ew.writeln(` - "UNITEDDOMAINS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/uniteddomains`) + case "variomedia": // generated from: providers/dns/variomedia/variomedia.toml ew.writeln(`Configuration for Variomedia.`) @@ -2845,11 +3875,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "VARIOMEDIA_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "VARIOMEDIA_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "VARIOMEDIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "VARIOMEDIA_SEQUENCE_INTERVAL": Time between sequential requests`) - ew.writeln(` - "VARIOMEDIA_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "VARIOMEDIA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "VARIOMEDIA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "VARIOMEDIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "VARIOMEDIA_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) + ew.writeln(` - "VARIOMEDIA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/variomedia`) @@ -2868,9 +3898,9 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "VEGADNS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "VEGADNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "VEGADNS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "VEGADNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 60)`) + ew.writeln(` - "VEGADNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 720)`) + ew.writeln(` - "VEGADNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/vegadns`) @@ -2887,11 +3917,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "VERCEL_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "VERCEL_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "VERCEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "VERCEL_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "VERCEL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) + ew.writeln(` - "VERCEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "VERCEL_TEAM_ID": Team ID (ex: team_xxxxxxxxxxxxxxxxxxxxxxxx)`) - ew.writeln(` - "VERCEL_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "VERCEL_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/vercel`) @@ -2910,11 +3940,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "VERSIO_ENDPOINT": The endpoint URL of the API Server`) - ew.writeln(` - "VERSIO_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "VERSIO_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "VERSIO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "VERSIO_SEQUENCE_INTERVAL": Time between sequential requests, default 60s`) - ew.writeln(` - "VERSIO_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "VERSIO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "VERSIO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) + ew.writeln(` - "VERSIO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "VERSIO_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) + ew.writeln(` - "VERSIO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/versio`) @@ -2933,13 +3963,35 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "VINYLDNS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "VINYLDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "VINYLDNS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "VINYLDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "VINYLDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`) + ew.writeln(` - "VINYLDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "VINYLDNS_QUOTE_VALUE": Adds quotes around the TXT record value (Default: false)`) + ew.writeln(` - "VINYLDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/vinyldns`) + case "virtualname": + // generated from: providers/dns/virtualname/virtualname.toml + ew.writeln(`Configuration for Virtualname.`) + ew.writeln(`Code: 'virtualname'`) + ew.writeln(`Since: 'v4.30.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "VIRTUALNAME_TOKEN": API token`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "VIRTUALNAME_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "VIRTUALNAME_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "VIRTUALNAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) + ew.writeln(` - "VIRTUALNAME_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/virtualname`) + case "vkcloud": // generated from: providers/dns/vkcloud/vkcloud.toml ew.writeln(`Configuration for VK Cloud.`) @@ -2957,9 +4009,9 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(` - "VK_CLOUD_DNS_ENDPOINT": URL of DNS API. Defaults to https://mcs.mail.ru/public-dns but can be changed for usage with private clouds`) ew.writeln(` - "VK_CLOUD_DOMAIN_NAME": Openstack users domain name. Defaults to 'users' but can be changed for usage with private clouds`) ew.writeln(` - "VK_CLOUD_IDENTITY_ENDPOINT": URL of OpenStack Auth API, Defaults to https://infra.mail.ru:35357/v3/ but can be changed for usage with private clouds`) - ew.writeln(` - "VK_CLOUD_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "VK_CLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "VK_CLOUD_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "VK_CLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "VK_CLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "VK_CLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/vkcloud`) @@ -2978,12 +4030,12 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "VOLC_HOST": API host`) - ew.writeln(` - "VOLC_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "VOLC_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "VOLC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "VOLC_HTTP_TIMEOUT": API request timeout in seconds (Default: 15)`) + ew.writeln(` - "VOLC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "VOLC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 240)`) ew.writeln(` - "VOLC_REGION": Region`) ew.writeln(` - "VOLC_SCHEME": API scheme`) - ew.writeln(` - "VOLC_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "VOLC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/volcengine`) @@ -3001,10 +4053,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "VSCALE_BASE_URL": API endpoint URL`) - ew.writeln(` - "VSCALE_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "VSCALE_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "VSCALE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "VSCALE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "VSCALE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "VSCALE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "VSCALE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "VSCALE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/vscale`) @@ -3021,34 +4073,54 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "VULTR_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "VULTR_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "VULTR_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "VULTR_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "VULTR_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "VULTR_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "VULTR_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "VULTR_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/vultr`) case "webnames": // generated from: providers/dns/webnames/webnames.toml - ew.writeln(`Configuration for Webnames.`) + ew.writeln(`Configuration for webnames.ru.`) ew.writeln(`Code: 'webnames'`) ew.writeln(`Since: 'v4.15.0'`) ew.writeln() ew.writeln(`Credentials:`) - ew.writeln(` - "WEBNAMES_API_KEY": Domain API key`) + ew.writeln(` - "WEBNAMESRU_API_KEY": Domain API key`) ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "WEBNAMES_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "WEBNAMES_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "WEBNAMES_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "WEBNAMES_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "WEBNAMESRU_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "WEBNAMESRU_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "WEBNAMESRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/webnames`) + case "webnamesca": + // generated from: providers/dns/webnamesca/webnamesca.toml + ew.writeln(`Configuration for webnames.ca.`) + ew.writeln(`Code: 'webnamesca'`) + ew.writeln(`Since: 'v4.28.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "WEBNAMESCA_API_KEY": API key`) + ew.writeln(` - "WEBNAMESCA_API_USER": API username`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "WEBNAMESCA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "WEBNAMESCA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "WEBNAMESCA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "WEBNAMESCA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/webnamesca`) + case "websupport": // generated from: providers/dns/websupport/websupport.toml ew.writeln(`Configuration for Websupport.`) @@ -3062,11 +4134,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "WEBSUPPORT_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "WEBSUPPORT_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "WEBSUPPORT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "WEBSUPPORT_SEQUENCE_INTERVAL": Time between sequential requests`) - ew.writeln(` - "WEBSUPPORT_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "WEBSUPPORT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "WEBSUPPORT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "WEBSUPPORT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "WEBSUPPORT_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) + ew.writeln(` - "WEBSUPPORT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/websupport`) @@ -3084,14 +4156,35 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "WEDOS_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "WEDOS_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "WEDOS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "WEDOS_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "WEDOS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "WEDOS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "WEDOS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`) + ew.writeln(` - "WEDOS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/wedos`) + case "westcn": + // generated from: providers/dns/westcn/westcn.toml + ew.writeln(`Configuration for West.cn/西部数码.`) + ew.writeln(`Code: 'westcn'`) + ew.writeln(`Since: 'v4.21.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "WESTCN_PASSWORD": API password`) + ew.writeln(` - "WESTCN_USERNAME": Username`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "WESTCN_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "WESTCN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) + ew.writeln(` - "WESTCN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "WESTCN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/westcn`) + case "yandex": // generated from: providers/dns/yandex/yandex.toml ew.writeln(`Configuration for Yandex PDD.`) @@ -3104,10 +4197,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "YANDEX_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "YANDEX_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "YANDEX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "YANDEX_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "YANDEX_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "YANDEX_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "YANDEX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "YANDEX_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/yandex`) @@ -3125,10 +4218,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "YANDEX360_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "YANDEX360_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "YANDEX360_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "YANDEX360_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "YANDEX360_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "YANDEX360_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "YANDEX360_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "YANDEX360_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/yandex360`) @@ -3146,13 +4239,33 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "YANDEX_CLOUD_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "YANDEX_CLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "YANDEX_CLOUD_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "YANDEX_CLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "YANDEX_CLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "YANDEX_CLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/yandexcloud`) + case "zoneedit": + // generated from: providers/dns/zoneedit/zoneedit.toml + ew.writeln(`Configuration for ZoneEdit.`) + ew.writeln(`Code: 'zoneedit'`) + ew.writeln(`Since: 'v4.25.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "ZONEEDIT_AUTH_TOKEN": Authentication token`) + ew.writeln(` - "ZONEEDIT_USER": User ID`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "ZONEEDIT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "ZONEEDIT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "ZONEEDIT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/zoneedit`) + case "zoneee": // generated from: providers/dns/zoneee/zoneee.toml ew.writeln(`Configuration for Zone.ee.`) @@ -3167,10 +4280,9 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "ZONEEE_ENDPOINT": API endpoint URL`) - ew.writeln(` - "ZONEEE_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "ZONEEE_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "ZONEEE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "ZONEEE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "ZONEEE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "ZONEEE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) + ew.writeln(` - "ZONEEE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/zoneee`) @@ -3187,16 +4299,14 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "ZONOMI_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "ZONOMI_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "ZONOMI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "ZONOMI_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "ZONOMI_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "ZONOMI_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "ZONOMI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "ZONOMI_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/zonomi`) - case "manual": - ew.writeln(`Solving the DNS-01 challenge using CLI prompt.`) default: return fmt.Errorf("%q is not yet supported", name) } diff --git a/docs/Makefile b/docs/Makefile index 8e32681d1..6c84c7d1d 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,14 +1,14 @@ -.PHONY: default clean hugo hugo-build +.PHONY: default clean serve build -default: clean hugo +default: clean serve clean: rm -rf public/ -hugo-build: clean +build: clean hugo --enableGitInfo --source . -hugo: +serve: hugo server --disableFastRender --enableGitInfo --watch --source . # hugo server -D diff --git a/docs/content/_index.md b/docs/content/_index.md index 6d9fc3f1a..95e411afc 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -7,23 +7,34 @@ chapter: false Let's Encrypt client and ACME library written in Go. +{{% notice important %}} +lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️ + +This project is not owned by a company. I'm not an employee of a company. + +I don't have gifted domains/accounts from DNS companies. + +I've been maintaining it for about 10 years. +{{% /notice %}} + ## Features - ACME v2 [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555.html) - Support [RFC 8737](https://www.rfc-editor.org/rfc/rfc8737.html): TLS Application‑Layer Protocol Negotiation (ALPN) Challenge Extension - Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): issues certificates for IP addresses - - Support [draft-ietf-acme-ari-01](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension + - Support [RFC 9773](https://www.rfc-editor.org/rfc/rfc9773.html): Renewal Information (ARI) Extension + - Support [draft-ietf-acme-profiles-00](https://datatracker.ietf.org/doc/draft-ietf-acme-profiles/): Profiles Extension +- Comes with about [180 DNS providers]({{% ref "dns" %}}) - Register with CA - Obtain certificates, both from scratch or with an existing CSR - Renew certificates - Revoke certificates -- Robust implementation of all ACME challenges +- Robust implementation of ACME challenges: - HTTP (http-01) - DNS (dns-01) - TLS (tls-alpn-01) - SAN certificate support - [CNAME support](https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme.html) by default -- Comes with multiple optional [DNS providers]({{% ref "dns" %}}) - [Custom challenge solvers]({{% ref "usage/library/Writing-a-Challenge-Solver" %}}) - Certificate bundling - OCSP helper function diff --git a/docs/content/dns/_index.md b/docs/content/dns/_index.md index 7ccfeb53d..2b6f0489c 100644 --- a/docs/content/dns/_index.md +++ b/docs/content/dns/_index.md @@ -5,6 +5,16 @@ draft: false weight: 3 --- +{{% notice important %}} +lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️ + +This project is not owned by a company. I'm not an employee of a company. + +I don't have gifted domains/accounts from DNS companies. + +I've been maintaining it for about 10 years. +{{% /notice %}} + ## Configuration and Credentials Credentials and DNS configuration for DNS providers must be passed through environment variables. diff --git a/docs/content/dns/zz_gen_acme-dns.md b/docs/content/dns/zz_gen_acme-dns.md index 0d57146ff..5564dba1b 100644 --- a/docs/content/dns/zz_gen_acme-dns.md +++ b/docs/content/dns/zz_gen_acme-dns.md @@ -28,7 +28,13 @@ Here is an example bash command using the Joohoi's ACME-DNS provider: ```bash ACME_DNS_API_BASE=http://10.0.0.8:4443 \ ACME_DNS_STORAGE_PATH=/root/.lego-acme-dns-accounts.json \ -lego --email you@example.com --dns "acme-dns" -d '*.example.com' -d example.com run +lego --dns "acme-dns" -d '*.example.com' -d example.com run + +# or + +ACME_DNS_API_BASE=http://10.0.0.8:4443 \ +ACME_DNS_STORAGE_BASE_URL=http://10.10.10.10:80 \ +lego --dns "acme-dns" -d '*.example.com' -d example.com run ``` @@ -39,20 +45,29 @@ lego --email you@example.com --dns "acme-dns" -d '*.example.com' -d example.com | Environment Variable Name | Description | |-----------------------|-------------| | `ACME_DNS_API_BASE` | The ACME-DNS API address | +| `ACME_DNS_STORAGE_BASE_URL` | The ACME-DNS JSON account data server. | | `ACME_DNS_STORAGE_PATH` | The ACME-DNS JSON account data file. A per-domain account will be registered/persisted to this file and used for TXT updates. | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `ACME_DNS_ALLOWLIST` | Source networks using CIDR notation (multiple values should be separated with a comma). | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + - - ## More information - [API documentation](https://github.com/joohoi/acme-dns#api) -- [Go client](https://github.com/cpu/goacmedns) +- [Go client](https://github.com/nrdcg/goacmedns) diff --git a/docs/content/dns/zz_gen_active24.md b/docs/content/dns/zz_gen_active24.md new file mode 100644 index 000000000..6ec5c467a --- /dev/null +++ b/docs/content/dns/zz_gen_active24.md @@ -0,0 +1,69 @@ +--- +title: "Active24" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: active24 +dnsprovider: + since: "v4.23.0" + code: "active24" + url: "https://www.active24.cz" +--- + + + + + + +Configuration for [Active24](https://www.active24.cz). + + + + +- Code: `active24` +- Since: v4.23.0 + + +Here is an example bash command using the Active24 provider: + +```bash +ACTIVE24_API_KEY="xxx" \ +ACTIVE24_SECRET="yyy" \ +lego --dns active24 -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `ACTIVE24_API_KEY` | API key | +| `ACTIVE24_SECRET` | Secret | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `ACTIVE24_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `ACTIVE24_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `ACTIVE24_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `ACTIVE24_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://rest.active24.cz/v2/docs) + + + + diff --git a/docs/content/dns/zz_gen_alidns.md b/docs/content/dns/zz_gen_alidns.md index d822ecea6..4ded782ab 100644 --- a/docs/content/dns/zz_gen_alidns.md +++ b/docs/content/dns/zz_gen_alidns.md @@ -28,13 +28,13 @@ Here is an example bash command using the Alibaba Cloud DNS provider: ```bash # Setup using instance RAM role ALICLOUD_RAM_ROLE=lego \ -lego --email you@example.com --dns alidns -d '*.example.com' -d example.com run +lego --dns alidns -d '*.example.com' -d example.com run # Or, using credentials ALICLOUD_ACCESS_KEY=abcdefghijklmnopqrstuvwx \ ALICLOUD_SECRET_KEY=your-secret-key \ ALICLOUD_SECURITY_TOKEN=your-sts-token \ -lego --email you@example.com --dns alidns - -d '*.example.com' -d example.com run +lego --dns alidns - -d '*.example.com' -d example.com run ``` @@ -45,7 +45,7 @@ lego --email you@example.com --dns alidns - -d '*.example.com' -d example.com ru | Environment Variable Name | Description | |-----------------------|-------------| | `ALICLOUD_ACCESS_KEY` | Access key ID | -| `ALICLOUD_RAM_ROLE` | Your instance RAM role (https://www.alibabacloud.com/help/doc-detail/54579.htm) | +| `ALICLOUD_RAM_ROLE` | Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance) | | `ALICLOUD_SECRET_KEY` | Access Key secret | | `ALICLOUD_SECURITY_TOKEN` | STS Security Token (optional) | @@ -57,10 +57,12 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `ALICLOUD_HTTP_TIMEOUT` | API request timeout | -| `ALICLOUD_POLLING_INTERVAL` | Time between DNS propagation check | -| `ALICLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `ALICLOUD_TTL` | The TTL of the TXT record used for the DNS challenge | +| `ALICLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `ALICLOUD_LINE` | Line (Default: default) | +| `ALICLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `ALICLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `ALICLOUD_REGION_ID` | Region ID (Default: cn-hangzhou) | +| `ALICLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). @@ -71,7 +73,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.alibabacloud.com/help/en/alibaba-cloud-dns/latest/api-alidns-2015-01-09-dir-parsing-records) -- [Go client](https://github.com/aliyun/alibaba-cloud-sdk-go) +- [Go client](https://github.com/alibabacloud-go/alidns-20150109) diff --git a/docs/content/dns/zz_gen_aliesa.md b/docs/content/dns/zz_gen_aliesa.md new file mode 100644 index 000000000..af28f9a4e --- /dev/null +++ b/docs/content/dns/zz_gen_aliesa.md @@ -0,0 +1,78 @@ +--- +title: "AlibabaCloud ESA" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: aliesa +dnsprovider: + since: "v4.29.0" + code: "aliesa" + url: "https://www.alibabacloud.com/en/product/esa" +--- + + + + + + +Configuration for [AlibabaCloud ESA](https://www.alibabacloud.com/en/product/esa). + + + + +- Code: `aliesa` +- Since: v4.29.0 + + +Here is an example bash command using the AlibabaCloud ESA provider: + +```bash +# Setup using instance RAM role +ALIESA_RAM_ROLE=lego \ +lego --dns aliesa -d '*.example.com' -d example.com run + +# Or, using credentials +ALIESA_ACCESS_KEY=abcdefghijklmnopqrstuvwx \ +ALIESA_SECRET_KEY=your-secret-key \ +ALIESA_SECURITY_TOKEN=your-sts-token \ +lego --dns aliesa - -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `ALIESA_ACCESS_KEY` | Access key ID | +| `ALIESA_RAM_ROLE` | Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance) | +| `ALIESA_SECRET_KEY` | Access Key secret | +| `ALIESA_SECURITY_TOKEN` | STS Security Token (optional) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `ALIESA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `ALIESA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `ALIESA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `ALIESA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-overview?spm=a2c63.p38356.help-menu-2673927.d_6_0_0.20b224c28PSZDc#:~:text=DNS-,DNS%20records,-DNS%20records) +- [Go client](https://github.com/alibabacloud-go/esa-20240910) + + + + diff --git a/docs/content/dns/zz_gen_allinkl.md b/docs/content/dns/zz_gen_allinkl.md index 08e354f87..2db6ae2c5 100644 --- a/docs/content/dns/zz_gen_allinkl.md +++ b/docs/content/dns/zz_gen_allinkl.md @@ -28,7 +28,7 @@ Here is an example bash command using the all-inkl provider: ```bash ALL_INKL_LOGIN=xxxxxxxxxxxxxxxxxxxxxxxxxx \ ALL_INKL_PASSWORD=yyyyyyyyyyyyyyyyyyyyyyyyyy \ -lego --email you@example.com --dns allinkl -d '*.example.com' -d example.com run +lego --dns allinkl -d '*.example.com' -d example.com run ``` @@ -49,9 +49,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `ALL_INKL_HTTP_TIMEOUT` | API request timeout | -| `ALL_INKL_POLLING_INTERVAL` | Time between DNS propagation check | -| `ALL_INKL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `ALL_INKL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `ALL_INKL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `ALL_INKL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_alwaysdata.md b/docs/content/dns/zz_gen_alwaysdata.md new file mode 100644 index 000000000..6ec332d16 --- /dev/null +++ b/docs/content/dns/zz_gen_alwaysdata.md @@ -0,0 +1,68 @@ +--- +title: "Alwaysdata" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: alwaysdata +dnsprovider: + since: "v4.31.0" + code: "alwaysdata" + url: "https://alwaysdata.com/" +--- + + + + + + +Configuration for [Alwaysdata](https://alwaysdata.com/). + + + + +- Code: `alwaysdata` +- Since: v4.31.0 + + +Here is an example bash command using the Alwaysdata provider: + +```bash +ALWAYSDATA_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns alwaysdata -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `ALWAYSDATA_API_KEY` | API Key | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `ALWAYSDATA_ACCOUNT` | Account name | +| `ALWAYSDATA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `ALWAYSDATA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `ALWAYSDATA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `ALWAYSDATA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://help.alwaysdata.com/en/api/resources/) + + + + diff --git a/docs/content/dns/zz_gen_anexia.md b/docs/content/dns/zz_gen_anexia.md new file mode 100644 index 000000000..e12ec7cfd --- /dev/null +++ b/docs/content/dns/zz_gen_anexia.md @@ -0,0 +1,73 @@ +--- +title: "Anexia CloudDNS" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: anexia +dnsprovider: + since: "v4.28.0" + code: "anexia" + url: "https://www.anexia-it.com/" +--- + + + + + + +Configuration for [Anexia CloudDNS](https://www.anexia-it.com/). + + + + +- Code: `anexia` +- Since: v4.28.0 + + +Here is an example bash command using the Anexia CloudDNS provider: + +```bash +ANEXIA_TOKEN=xxx \ +lego --dns anexia -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `ANEXIA_TOKEN` | API token for Anexia Engine | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `ANEXIA_API_URL` | API endpoint URL (default: https://engine.anexia-it.com) | +| `ANEXIA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `ANEXIA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `ANEXIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | +| `ANEXIA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + +## Description + +You need to create an API token in the [Anexia Engine](https://engine.anexia-it.com/). + +The token must have permissions to manage DNS zones and records. + + + +## More information + +- [API documentation](https://engine.anexia-it.com/docs/en/module/clouddns/api) + + + + diff --git a/docs/content/dns/zz_gen_artfiles.md b/docs/content/dns/zz_gen_artfiles.md new file mode 100644 index 000000000..15ac2d964 --- /dev/null +++ b/docs/content/dns/zz_gen_artfiles.md @@ -0,0 +1,69 @@ +--- +title: "ArtFiles" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: artfiles +dnsprovider: + since: "v4.32.0" + code: "artfiles" + url: "https://www.artfiles.de/extras/domains/" +--- + + + + + + +Configuration for [ArtFiles](https://www.artfiles.de/extras/domains/). + + + + +- Code: `artfiles` +- Since: v4.32.0 + + +Here is an example bash command using the ArtFiles provider: + +```bash +ARTFILES_USERNAME="xxx" \ +ARTFILES_PASSWORD="yyy" \ +lego --dns artfiles -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `ARTFILES_PASSWORD` | API password | +| `ARTFILES_USERNAME` | API username | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `ARTFILES_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `ARTFILES_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `ARTFILES_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 360) | +| `ARTFILES_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://support.artfiles.de/DCP-API#dns) + + + + diff --git a/docs/content/dns/zz_gen_arvancloud.md b/docs/content/dns/zz_gen_arvancloud.md index ff03f22e1..96d495f71 100644 --- a/docs/content/dns/zz_gen_arvancloud.md +++ b/docs/content/dns/zz_gen_arvancloud.md @@ -27,7 +27,7 @@ Here is an example bash command using the ArvanCloud provider: ```bash ARVANCLOUD_API_KEY="Apikey xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ -lego --email you@example.com --dns arvancloud -d '*.example.com' -d example.com run +lego --dns arvancloud -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `ARVANCLOUD_HTTP_TIMEOUT` | API request timeout | -| `ARVANCLOUD_POLLING_INTERVAL` | Time between DNS propagation check | -| `ARVANCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `ARVANCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge | +| `ARVANCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `ARVANCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `ARVANCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `ARVANCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_auroradns.md b/docs/content/dns/zz_gen_auroradns.md index d3fa5a1df..d608c85bb 100644 --- a/docs/content/dns/zz_gen_auroradns.md +++ b/docs/content/dns/zz_gen_auroradns.md @@ -28,7 +28,7 @@ Here is an example bash command using the Aurora DNS provider: ```bash AURORA_API_KEY=xxxxx \ AURORA_SECRET=yyyyyy \ -lego --email you@example.com --dns auroradns -d '*.example.com' -d example.com run +lego --dns auroradns -d '*.example.com' -d example.com run ``` @@ -50,9 +50,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `AURORA_ENDPOINT` | API endpoint URL | -| `AURORA_POLLING_INTERVAL` | Time between DNS propagation check | -| `AURORA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `AURORA_TTL` | The TTL of the TXT record used for the DNS challenge | +| `AURORA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `AURORA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `AURORA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_autodns.md b/docs/content/dns/zz_gen_autodns.md index 584f21770..f1f25e916 100644 --- a/docs/content/dns/zz_gen_autodns.md +++ b/docs/content/dns/zz_gen_autodns.md @@ -28,7 +28,7 @@ Here is an example bash command using the Autodns provider: ```bash AUTODNS_API_USER=username \ AUTODNS_API_PASSWORD=supersecretpassword \ -lego --email you@example.com --dns autodns -d '*.example.com' -d example.com run +lego --dns autodns -d '*.example.com' -d example.com run ``` @@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). |--------------------------------|-------------| | `AUTODNS_CONTEXT` | API context (4 for production, 1 for testing. Defaults to 4) | | `AUTODNS_ENDPOINT` | API endpoint URL, defaults to https://api.autodns.com/v1/ | -| `AUTODNS_HTTP_TIMEOUT` | API request timeout, defaults to 30 seconds | -| `AUTODNS_POLLING_INTERVAL` | Time between DNS propagation check | -| `AUTODNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `AUTODNS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `AUTODNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `AUTODNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `AUTODNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `AUTODNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_axelname.md b/docs/content/dns/zz_gen_axelname.md new file mode 100644 index 000000000..91476e521 --- /dev/null +++ b/docs/content/dns/zz_gen_axelname.md @@ -0,0 +1,69 @@ +--- +title: "Axelname" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: axelname +dnsprovider: + since: "v4.23.0" + code: "axelname" + url: "https://axelname.ru" +--- + + + + + + +Configuration for [Axelname](https://axelname.ru). + + + + +- Code: `axelname` +- Since: v4.23.0 + + +Here is an example bash command using the Axelname provider: + +```bash +AXELNAME_NICKNAME="yyy" \ +AXELNAME_TOKEN="xxx" \ +lego --dns axelname -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `AXELNAME_NICKNAME` | Account nickname | +| `AXELNAME_TOKEN` | API token | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `AXELNAME_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `AXELNAME_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `AXELNAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `AXELNAME_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://axelname.ru/static/content/files/axelname_api_rest_lite.pdf) + + + + diff --git a/docs/content/dns/zz_gen_azion.md b/docs/content/dns/zz_gen_azion.md new file mode 100644 index 000000000..c5ca33552 --- /dev/null +++ b/docs/content/dns/zz_gen_azion.md @@ -0,0 +1,69 @@ +--- +title: "Azion" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: azion +dnsprovider: + since: "v4.24.0" + code: "azion" + url: "https://www.azion.com/en/products/edge-dns/" +--- + + + + + + +Configuration for [Azion](https://www.azion.com/en/products/edge-dns/). + + + + +- Code: `azion` +- Since: v4.24.0 + + +Here is an example bash command using the Azion provider: + +```bash +AZION_PERSONAL_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxx \ +lego --dns azion -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `AZION_PERSONAL_TOKEN` | Your Azion personal token. | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `AZION_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `AZION_PAGE_SIZE` | The page size for the API request (Default: 50) | +| `AZION_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `AZION_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `AZION_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://api.azion.com/) +- [Go client](https://github.com/aziontech/azionapi-go-sdk) + + + + diff --git a/docs/content/dns/zz_gen_azure.md b/docs/content/dns/zz_gen_azure.md index e1ecd9506..5063c202f 100644 --- a/docs/content/dns/zz_gen_azure.md +++ b/docs/content/dns/zz_gen_azure.md @@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `AZURE_METADATA_ENDPOINT` | Metadata Service endpoint URL | -| `AZURE_POLLING_INTERVAL` | Time between DNS propagation check | +| `AZURE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `AZURE_PRIVATE_ZONE` | Set to true to use Azure Private DNS Zones and not public | -| `AZURE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `AZURE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `AZURE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `AZURE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | | `AZURE_ZONE_NAME` | Zone name to use inside Azure DNS service to add the TXT record in | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. diff --git a/docs/content/dns/zz_gen_azuredns.md b/docs/content/dns/zz_gen_azuredns.md index 4b762e675..3b2586711 100644 --- a/docs/content/dns/zz_gen_azuredns.md +++ b/docs/content/dns/zz_gen_azuredns.md @@ -31,32 +31,32 @@ Here is an example bash command using the Azure DNS provider: AZURE_CLIENT_ID= \ AZURE_TENANT_ID= \ AZURE_CLIENT_SECRET= \ -lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run +lego --dns azuredns -d '*.example.com' -d example.com run ### Using client certificate AZURE_CLIENT_ID= \ AZURE_TENANT_ID= \ AZURE_CLIENT_CERTIFICATE_PATH= \ -lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run +lego --dns azuredns -d '*.example.com' -d example.com run ### Using Azure CLI az login \ -lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run +lego --dns azuredns -d '*.example.com' -d example.com run ### Using Managed Identity (Azure VM) AZURE_TENANT_ID= \ AZURE_RESOURCE_GROUP= \ -lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run +lego --dns azuredns -d '*.example.com' -d example.com run ### Using Managed Identity (Azure Arc) AZURE_TENANT_ID= \ IMDS_ENDPOINT=http://localhost:40342 \ IDENTITY_ENDPOINT=http://localhost:40342/metadata/identity/oauth2/token \ -lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run +lego --dns azuredns -d '*.example.com' -d example.com run ``` @@ -83,13 +83,13 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | `AZURE_AUTH_METHOD` | Specify which authentication method to use | | `AZURE_AUTH_MSI_TIMEOUT` | Managed Identity timeout duration | | `AZURE_ENVIRONMENT` | Azure environment, one of: public, usgovernment, and china | -| `AZURE_POLLING_INTERVAL` | Time between DNS propagation check | +| `AZURE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `AZURE_PRIVATE_ZONE` | Set to true to use Azure Private DNS Zones and not public | -| `AZURE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `AZURE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `AZURE_RESOURCE_GROUP` | DNS zone resource group | | `AZURE_SERVICEDISCOVERY_FILTER` | Advanced ServiceDiscovery filter using Kusto query condition | | `AZURE_SUBSCRIPTION_ID` | DNS zone subscription ID | -| `AZURE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `AZURE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | | `AZURE_ZONE_NAME` | Zone name to use inside Azure DNS service to add the TXT record in | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. @@ -229,6 +229,10 @@ This authentication method can be specifically used by setting the `AZURE_AUTH_M Open ID Connect is a mechanism that establish a trust relationship between a running environment and the Azure AD identity provider. It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `oidc`. +### Azure DevOps Pipelines + +It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `pipeline`. + diff --git a/docs/content/dns/zz_gen_baiducloud.md b/docs/content/dns/zz_gen_baiducloud.md new file mode 100644 index 000000000..59a2f9a2d --- /dev/null +++ b/docs/content/dns/zz_gen_baiducloud.md @@ -0,0 +1,69 @@ +--- +title: "Baidu Cloud" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: baiducloud +dnsprovider: + since: "v4.23.0" + code: "baiducloud" + url: "https://cloud.baidu.com" +--- + + + + + + +Configuration for [Baidu Cloud](https://cloud.baidu.com). + + + + +- Code: `baiducloud` +- Since: v4.23.0 + + +Here is an example bash command using the Baidu Cloud provider: + +```bash +BAIDUCLOUD_ACCESS_KEY_ID="xxx" \ +BAIDUCLOUD_SECRET_ACCESS_KEY="yyy" \ +lego --dns baiducloud -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `BAIDUCLOUD_ACCESS_KEY_ID` | Access key | +| `BAIDUCLOUD_SECRET_ACCESS_KEY` | Secret access key | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `BAIDUCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `BAIDUCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `BAIDUCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://cloud.baidu.com/doc/DNS/s/El4s7lssr) +- [Go client](https://github.com/baidubce/bce-sdk-go) + + + + diff --git a/docs/content/dns/zz_gen_beget.md b/docs/content/dns/zz_gen_beget.md new file mode 100644 index 000000000..3f03a2ac5 --- /dev/null +++ b/docs/content/dns/zz_gen_beget.md @@ -0,0 +1,69 @@ +--- +title: "Beget.com" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: beget +dnsprovider: + since: "v4.27.0" + code: "beget" + url: "https://beget.com/" +--- + + + + + + +Configuration for [Beget.com](https://beget.com/). + + + + +- Code: `beget` +- Since: v4.27.0 + + +Here is an example bash command using the Beget.com provider: + +```bash +BEGET_USERNAME=xxxxxx \ +BEGET_PASSWORD=yyyyyy \ +lego --dns beget -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `BEGET_PASSWORD` | API password | +| `BEGET_USERNAME` | API username | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `BEGET_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `BEGET_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) | +| `BEGET_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | +| `BEGET_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://beget.com/ru/kb/api/funkczii-upravleniya-dns) + + + + diff --git a/docs/content/dns/zz_gen_binarylane.md b/docs/content/dns/zz_gen_binarylane.md new file mode 100644 index 000000000..eebf3c54e --- /dev/null +++ b/docs/content/dns/zz_gen_binarylane.md @@ -0,0 +1,67 @@ +--- +title: "Binary Lane" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: binarylane +dnsprovider: + since: "v4.26.0" + code: "binarylane" + url: "https://www.binarylane.com.au/" +--- + + + + + + +Configuration for [Binary Lane](https://www.binarylane.com.au/). + + + + +- Code: `binarylane` +- Since: v4.26.0 + + +Here is an example bash command using the Binary Lane provider: + +```bash +BINARYLANE_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns binarylane -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `BINARYLANE_API_TOKEN` | API token | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `BINARYLANE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `BINARYLANE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `BINARYLANE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `BINARYLANE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://api.binarylane.com.au/reference/#tag/Domains) + + + + diff --git a/docs/content/dns/zz_gen_bindman.md b/docs/content/dns/zz_gen_bindman.md index c74273a7f..fcceb8962 100644 --- a/docs/content/dns/zz_gen_bindman.md +++ b/docs/content/dns/zz_gen_bindman.md @@ -27,7 +27,7 @@ Here is an example bash command using the Bindman provider: ```bash BINDMAN_MANAGER_ADDRESS= \ -lego --email you@example.com --dns bindman -d '*.example.com' -d example.com run +lego --dns bindman -d '*.example.com' -d example.com run ``` @@ -47,9 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `BINDMAN_HTTP_TIMEOUT` | API request timeout | -| `BINDMAN_POLLING_INTERVAL` | Time between DNS propagation check | -| `BINDMAN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `BINDMAN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | +| `BINDMAN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `BINDMAN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_bluecat.md b/docs/content/dns/zz_gen_bluecat.md index 3b0ebf898..2d9eb5b48 100644 --- a/docs/content/dns/zz_gen_bluecat.md +++ b/docs/content/dns/zz_gen_bluecat.md @@ -32,7 +32,7 @@ BLUECAT_USER_NAME=myusername \ BLUECAT_CONFIG_NAME=myconfig \ BLUECAT_SERVER_URL=https://bam.example.com \ BLUECAT_TTL=30 \ -lego --email you@example.com --dns bluecat -d '*.example.com' -d example.com run +lego --dns bluecat -d '*.example.com' -d example.com run ``` @@ -56,11 +56,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `BLUECAT_HTTP_TIMEOUT` | API request timeout | -| `BLUECAT_POLLING_INTERVAL` | Time between DNS propagation check | -| `BLUECAT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `BLUECAT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `BLUECAT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `BLUECAT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `BLUECAT_SKIP_DEPLOY` | Skip deployements | -| `BLUECAT_TTL` | The TTL of the TXT record used for the DNS challenge | +| `BLUECAT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_bluecatv2.md b/docs/content/dns/zz_gen_bluecatv2.md new file mode 100644 index 000000000..7d748df99 --- /dev/null +++ b/docs/content/dns/zz_gen_bluecatv2.md @@ -0,0 +1,76 @@ +--- +title: "Bluecat v2" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: bluecatv2 +dnsprovider: + since: "v4.32.0" + code: "bluecatv2" + url: "https://www.bluecatnetworks.com" +--- + + + + + + +Configuration for [Bluecat v2](https://www.bluecatnetworks.com). + + + + +- Code: `bluecatv2` +- Since: v4.32.0 + + +Here is an example bash command using the Bluecat v2 provider: + +```bash +BLUECATV2_SERVER_URL="https://example.com" \ +BLUECATV2_USERNAME="xxx" \ +BLUECATV2_PASSWORD="yyy" \ +BLUECATV2_CONFIG_NAME="myConfiguration" \ +BLUECATV2_VIEW_NAME="myView" \ +lego --dns bluecatv2 -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `BLUECATV2_CONFIG_NAME` | Configuration name | +| `BLUECATV2_PASSWORD` | API password | +| `BLUECATV2_USERNAME` | API username | +| `BLUECATV2_VIEW_NAME` | DNS View Name | +| `BLUECAT_SERVER_URL` | The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `BLUECATV2_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `BLUECATV2_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `BLUECATV2_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `BLUECATV2_SKIP_DEPLOY` | Skip quick deployements | +| `BLUECATV2_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Introduction/9.6.0) + + + + diff --git a/docs/content/dns/zz_gen_bookmyname.md b/docs/content/dns/zz_gen_bookmyname.md new file mode 100644 index 000000000..cb7e1d3a1 --- /dev/null +++ b/docs/content/dns/zz_gen_bookmyname.md @@ -0,0 +1,69 @@ +--- +title: "BookMyName" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: bookmyname +dnsprovider: + since: "v4.23.0" + code: "bookmyname" + url: "https://www.bookmyname.com/" +--- + + + + + + +Configuration for [BookMyName](https://www.bookmyname.com/). + + + + +- Code: `bookmyname` +- Since: v4.23.0 + + +Here is an example bash command using the BookMyName provider: + +```bash +BOOKMYNAME_USERNAME="xxx" \ +BOOKMYNAME_PASSWORD="yyy" \ +lego --dns bookmyname -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `BOOKMYNAME_PASSWORD` | Password | +| `BOOKMYNAME_USERNAME` | Username | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `BOOKMYNAME_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `BOOKMYNAME_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `BOOKMYNAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `BOOKMYNAME_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://fr.faqs.bookmyname.com/frfaqs/dyndns) + + + + diff --git a/docs/content/dns/zz_gen_brandit.md b/docs/content/dns/zz_gen_brandit.md index c2264f71c..fdb538684 100644 --- a/docs/content/dns/zz_gen_brandit.md +++ b/docs/content/dns/zz_gen_brandit.md @@ -31,7 +31,7 @@ Here is an example bash command using the Brandit (deprecated) provider: ```bash BRANDIT_API_KEY=xxxxxxxxxxxxxxxxxxxxx \ BRANDIT_API_USERNAME=yyyyyyyyyyyyyyyyyyyy \ -lego --email you@example.com --dns brandit -d '*.example.com' -d example.com run +lego --dns brandit -d '*.example.com' -d example.com run ``` @@ -52,10 +52,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `BRANDIT_HTTP_TIMEOUT` | API request timeout | -| `BRANDIT_POLLING_INTERVAL` | Time between DNS propagation check | -| `BRANDIT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `BRANDIT_TTL` | The TTL of the TXT record used for the DNS challenge | +| `BRANDIT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `BRANDIT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `BRANDIT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) | +| `BRANDIT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_bunny.md b/docs/content/dns/zz_gen_bunny.md index f945b9153..63c30782a 100644 --- a/docs/content/dns/zz_gen_bunny.md +++ b/docs/content/dns/zz_gen_bunny.md @@ -27,7 +27,7 @@ Here is an example bash command using the Bunny provider: ```bash BUNNY_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ -lego --email you@example.com --dns bunny -d '*.example.com' -d example.com run +lego --dns bunny -d '*.example.com' -d example.com run ``` @@ -47,9 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `BUNNY_POLLING_INTERVAL` | Time between DNS propagation check | -| `BUNNY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `BUNNY_TTL` | The TTL of the TXT record used for the DNS challenge | +| `BUNNY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `BUNNY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `BUNNY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `BUNNY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_checkdomain.md b/docs/content/dns/zz_gen_checkdomain.md index 694b8cc67..e0275f6c9 100644 --- a/docs/content/dns/zz_gen_checkdomain.md +++ b/docs/content/dns/zz_gen_checkdomain.md @@ -27,7 +27,7 @@ Here is an example bash command using the Checkdomain provider: ```bash CHECKDOMAIN_TOKEN=yoursecrettoken \ -lego --email you@example.com --dns checkdomain -d '*.example.com' -d example.com run +lego --dns checkdomain -d '*.example.com' -d example.com run ``` @@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `CHECKDOMAIN_ENDPOINT` | API endpoint URL, defaults to https://api.checkdomain.de | -| `CHECKDOMAIN_HTTP_TIMEOUT` | API request timeout, defaults to 30 seconds | -| `CHECKDOMAIN_POLLING_INTERVAL` | Time between DNS propagation check | -| `CHECKDOMAIN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `CHECKDOMAIN_TTL` | The TTL of the TXT record used for the DNS challenge | +| `CHECKDOMAIN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `CHECKDOMAIN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 300) | +| `CHECKDOMAIN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 7) | +| `CHECKDOMAIN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_civo.md b/docs/content/dns/zz_gen_civo.md index 73f04140d..61303b539 100644 --- a/docs/content/dns/zz_gen_civo.md +++ b/docs/content/dns/zz_gen_civo.md @@ -27,7 +27,7 @@ Here is an example bash command using the Civo provider: ```bash CIVO_TOKEN=xxxxxx \ -lego --email you@example.com --dns civo -d '*.example.com' -d example.com run +lego --dns civo -d '*.example.com' -d example.com run ``` @@ -47,9 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `CIVO_POLLING_INTERVAL` | Time between DNS propagation check | -| `CIVO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `CIVO_TTL` | The TTL of the TXT record used for the DNS challenge | +| `CIVO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) | +| `CIVO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | +| `CIVO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_clouddns.md b/docs/content/dns/zz_gen_clouddns.md index 4754cebca..d10d1d6a1 100644 --- a/docs/content/dns/zz_gen_clouddns.md +++ b/docs/content/dns/zz_gen_clouddns.md @@ -29,7 +29,7 @@ Here is an example bash command using the CloudDNS provider: CLOUDDNS_CLIENT_ID=bLsdFAks23429841238feb177a572aX \ CLOUDDNS_EMAIL=you@example.com \ CLOUDDNS_PASSWORD=b9841238feb177a84330f \ -lego --email you@example.com --dns clouddns -d '*.example.com' -d example.com run +lego --dns clouddns -d '*.example.com' -d example.com run ``` @@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `CLOUDDNS_HTTP_TIMEOUT` | API request timeout | -| `CLOUDDNS_POLLING_INTERVAL` | Time between DNS propagation check | -| `CLOUDDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `CLOUDDNS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `CLOUDDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `CLOUDDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | +| `CLOUDDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `CLOUDDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_cloudflare.md b/docs/content/dns/zz_gen_cloudflare.md index 55fbaeae3..f3390a5fd 100644 --- a/docs/content/dns/zz_gen_cloudflare.md +++ b/docs/content/dns/zz_gen_cloudflare.md @@ -28,12 +28,12 @@ Here is an example bash command using the Cloudflare provider: ```bash CLOUDFLARE_EMAIL=you@example.com \ CLOUDFLARE_API_KEY=b9841238feb177a84330febba8a83208921177bffe733 \ -lego --email you@example.com --dns cloudflare -d '*.example.com' -d example.com run +lego --dns cloudflare -d '*.example.com' -d example.com run # or CLOUDFLARE_DNS_API_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \ -lego --email you@example.com --dns cloudflare -d '*.example.com' -d example.com run +lego --dns cloudflare -d '*.example.com' -d example.com run ``` @@ -60,10 +60,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `CLOUDFLARE_HTTP_TIMEOUT` | API request timeout (in seconds) | -| `CLOUDFLARE_POLLING_INTERVAL` | Time between DNS propagation check (in seconds) | -| `CLOUDFLARE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation (in seconds) | -| `CLOUDFLARE_TTL` | The TTL of the TXT record used for the DNS challenge (in seconds) | +| `CLOUDFLARE_BASE_URL` | API base URL (Default: https://api.cloudflare.com/client/v4) | +| `CLOUDFLARE_HTTP_TIMEOUT` | API request timeout in seconds (Default: ) | +| `CLOUDFLARE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `CLOUDFLARE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `CLOUDFLARE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_cloudns.md b/docs/content/dns/zz_gen_cloudns.md index f063d835f..26bd838f2 100644 --- a/docs/content/dns/zz_gen_cloudns.md +++ b/docs/content/dns/zz_gen_cloudns.md @@ -28,7 +28,7 @@ Here is an example bash command using the ClouDNS provider: ```bash CLOUDNS_AUTH_ID=xxxx \ CLOUDNS_AUTH_PASSWORD=yyyy \ -lego --email you@example.com --dns cloudns -d '*.example.com' -d example.com run +lego --dns cloudns -d '*.example.com' -d example.com run ``` @@ -49,11 +49,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `CLOUDNS_HTTP_TIMEOUT` | API request timeout | -| `CLOUDNS_POLLING_INTERVAL` | Time between DNS propagation check | -| `CLOUDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `CLOUDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `CLOUDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `CLOUDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 180) | | `CLOUDNS_SUB_AUTH_ID` | The API sub user ID | -| `CLOUDNS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `CLOUDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_cloudru.md b/docs/content/dns/zz_gen_cloudru.md index b4cb9dcac..6dc3b0030 100644 --- a/docs/content/dns/zz_gen_cloudru.md +++ b/docs/content/dns/zz_gen_cloudru.md @@ -29,7 +29,7 @@ Here is an example bash command using the Cloud.ru provider: CLOUDRU_SERVICE_INSTANCE_ID=ppp \ CLOUDRU_KEY_ID=xxx \ CLOUDRU_SECRET=yyy \ -lego --email you@example.com --dns cloudru -d '*.example.com' -d example.com run +lego --dns cloudru -d '*.example.com' -d example.com run ``` @@ -51,11 +51,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `CLOUDRU_HTTP_TIMEOUT` | API request timeout | -| `CLOUDRU_POLLING_INTERVAL` | Time between DNS propagation check | -| `CLOUDRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `CLOUDRU_SEQUENCE_INTERVAL` | Time between sequential requests | -| `CLOUDRU_TTL` | The TTL of the TXT record used for the DNS challenge | +| `CLOUDRU_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `CLOUDRU_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | +| `CLOUDRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | +| `CLOUDRU_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 120) | +| `CLOUDRU_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_cloudxns.md b/docs/content/dns/zz_gen_cloudxns.md index c63a773e1..b26e5ddb5 100644 --- a/docs/content/dns/zz_gen_cloudxns.md +++ b/docs/content/dns/zz_gen_cloudxns.md @@ -28,7 +28,7 @@ Here is an example bash command using the CloudXNS (Deprecated) provider: ```bash CLOUDXNS_API_KEY=xxxx \ CLOUDXNS_SECRET_KEY=yyyy \ -lego --email you@example.com --dns cloudxns -d '*.example.com' -d example.com run +lego --dns cloudxns -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `CLOUDXNS_HTTP_TIMEOUT` | API request timeout | -| `CLOUDXNS_POLLING_INTERVAL` | Time between DNS propagation check | -| `CLOUDXNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `CLOUDXNS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `CLOUDXNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: ) | +| `CLOUDXNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: ) | +| `CLOUDXNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: ) | +| `CLOUDXNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: ) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_com35.md b/docs/content/dns/zz_gen_com35.md new file mode 100644 index 000000000..e2552e57c --- /dev/null +++ b/docs/content/dns/zz_gen_com35.md @@ -0,0 +1,69 @@ +--- +title: "35.com/三五互联" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: com35 +dnsprovider: + since: "v4.31.0" + code: "com35" + url: "https://www.35.cn/" +--- + + + + + + +Configuration for [35.com/三五互联](https://www.35.cn/). + + + + +- Code: `com35` +- Since: v4.31.0 + + +Here is an example bash command using the 35.com/三五互联 provider: + +```bash +COM35_USERNAME="xxx" \ +COM35_PASSWORD="yyy" \ +lego --dns com35 -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `COM35_PASSWORD` | API password | +| `COM35_USERNAME` | Username | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `COM35_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `COM35_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `COM35_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `COM35_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://api.35.cn/CustomerCenter/doc/domain_v2.html) + + + + diff --git a/docs/content/dns/zz_gen_conoha.md b/docs/content/dns/zz_gen_conoha.md index c5de0d20e..08a979b31 100644 --- a/docs/content/dns/zz_gen_conoha.md +++ b/docs/content/dns/zz_gen_conoha.md @@ -1,5 +1,5 @@ --- -title: "ConoHa" +title: "ConoHa v2" date: 2019-03-03T16:39:46+01:00 draft: false slug: conoha @@ -14,7 +14,7 @@ dnsprovider: -Configuration for [ConoHa](https://www.conoha.jp/). +Configuration for [ConoHa v2](https://www.conoha.jp/). @@ -23,13 +23,13 @@ Configuration for [ConoHa](https://www.conoha.jp/). - Since: v1.2.0 -Here is an example bash command using the ConoHa provider: +Here is an example bash command using the ConoHa v2 provider: ```bash CONOHA_TENANT_ID=487727e3921d44e3bfe7ebb337bf085e \ CONOHA_API_USERNAME=xxxx \ CONOHA_API_PASSWORD=yyyy \ -lego --email you@example.com --dns conoha -d '*.example.com' -d example.com run +lego --dns conoha -d '*.example.com' -d example.com run ``` @@ -51,11 +51,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `CONOHA_HTTP_TIMEOUT` | API request timeout | -| `CONOHA_POLLING_INTERVAL` | Time between DNS propagation check | -| `CONOHA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `CONOHA_REGION` | The region | -| `CONOHA_TTL` | The TTL of the TXT record used for the DNS challenge | +| `CONOHA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `CONOHA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `CONOHA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `CONOHA_REGION` | The region (Default: tyo1) | +| `CONOHA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). @@ -65,7 +65,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information -- [API documentation](https://www.conoha.jp/docs/) +- [API documentation](https://doc.conoha.jp/reference/api-vps2/api-dns-vps2) diff --git a/docs/content/dns/zz_gen_conohav3.md b/docs/content/dns/zz_gen_conohav3.md new file mode 100644 index 000000000..e473f9434 --- /dev/null +++ b/docs/content/dns/zz_gen_conohav3.md @@ -0,0 +1,72 @@ +--- +title: "ConoHa v3" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: conohav3 +dnsprovider: + since: "v4.24.0" + code: "conohav3" + url: "https://www.conoha.jp/" +--- + + + + + + +Configuration for [ConoHa v3](https://www.conoha.jp/). + + + + +- Code: `conohav3` +- Since: v4.24.0 + + +Here is an example bash command using the ConoHa v3 provider: + +```bash +CONOHAV3_TENANT_ID=487727e3921d44e3bfe7ebb337bf085e \ +CONOHAV3_API_USER_ID=xxxx \ +CONOHAV3_API_PASSWORD=yyyy \ +lego --dns conohav3 -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `CONOHAV3_API_PASSWORD` | The API password | +| `CONOHAV3_API_USER_ID` | The API user ID | +| `CONOHAV3_TENANT_ID` | Tenant ID | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `CONOHAV3_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `CONOHAV3_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `CONOHAV3_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `CONOHAV3_REGION` | The region (Default: c3j1) | +| `CONOHAV3_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/) + + + + diff --git a/docs/content/dns/zz_gen_constellix.md b/docs/content/dns/zz_gen_constellix.md index 69040353d..d4ce02bac 100644 --- a/docs/content/dns/zz_gen_constellix.md +++ b/docs/content/dns/zz_gen_constellix.md @@ -28,7 +28,7 @@ Here is an example bash command using the Constellix provider: ```bash CONSTELLIX_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ CONSTELLIX_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ -lego --email you@example.com --dns constellix -d '*.example.com' -d example.com run +lego --dns constellix -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `CONSTELLIX_HTTP_TIMEOUT` | API request timeout | -| `CONSTELLIX_POLLING_INTERVAL` | Time between DNS propagation check | -| `CONSTELLIX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `CONSTELLIX_TTL` | The TTL of the TXT record used for the DNS challenge | +| `CONSTELLIX_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `CONSTELLIX_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `CONSTELLIX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `CONSTELLIX_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_corenetworks.md b/docs/content/dns/zz_gen_corenetworks.md index 0b61bbc77..05468b1a3 100644 --- a/docs/content/dns/zz_gen_corenetworks.md +++ b/docs/content/dns/zz_gen_corenetworks.md @@ -28,7 +28,7 @@ Here is an example bash command using the Core-Networks provider: ```bash CORENETWORKS_LOGIN="xxxx" \ CORENETWORKS_PASSWORD="yyyy" \ -lego --email you@example.com --dns corenetworks -d '*.example.com' -d example.com run +lego --dns corenetworks -d '*.example.com' -d example.com run ``` @@ -49,11 +49,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `CORENETWORKS_HTTP_TIMEOUT` | API request timeout | -| `CORENETWORKS_POLLING_INTERVAL` | Time between DNS propagation check | -| `CORENETWORKS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `CORENETWORKS_SEQUENCE_INTERVAL` | Time between sequential requests | -| `CORENETWORKS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `CORENETWORKS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `CORENETWORKS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `CORENETWORKS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `CORENETWORKS_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | +| `CORENETWORKS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_cpanel.md b/docs/content/dns/zz_gen_cpanel.md index 9e939ca59..e5c0cc047 100644 --- a/docs/content/dns/zz_gen_cpanel.md +++ b/docs/content/dns/zz_gen_cpanel.md @@ -28,18 +28,18 @@ Here is an example bash command using the CPanel/WHM provider: ```bash ### CPANEL (default) -CPANEL_USERNAME = "yyyy" -CPANEL_TOKEN = "xxxx" -CPANEL_BASE_URL = "https://example.com:2083" \ -lego --email you@example.com --dns cpanel -d '*.example.com' -d example.com run +CPANEL_USERNAME="yyyy" \ +CPANEL_TOKEN="xxxx" \ +CPANEL_BASE_URL="https://example.com:2083" \ +lego --dns cpanel -d '*.example.com' -d example.com run ## WHM -CPANEL_MODE = whm -CPANEL_USERNAME = "yyyy" -CPANEL_TOKEN = "xxxx" -CPANEL_BASE_URL = "https://example.com:2087" \ -lego --email you@example.com --dns cpanel -d '*.example.com' -d example.com run +CPANEL_MODE=whm \ +CPANEL_USERNAME="yyyy" \ +CPANEL_TOKEN="xxxx" \ +CPANEL_BASE_URL="https://example.com:2087" \ +lego --dns cpanel -d '*.example.com' -d example.com run ``` @@ -61,12 +61,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `CPANEL_HTTP_TIMEOUT` | API request timeout | +| `CPANEL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `CPANEL_MODE` | use cpanel API or WHM API (Default: cpanel) | -| `CPANEL_POLLING_INTERVAL` | Time between DNS propagation check | -| `CPANEL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `CPANEL_REGION` | The region | -| `CPANEL_TTL` | The TTL of the TXT record used for the DNS challenge | +| `CPANEL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `CPANEL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `CPANEL_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_czechia.md b/docs/content/dns/zz_gen_czechia.md new file mode 100644 index 000000000..7b1cdd1ae --- /dev/null +++ b/docs/content/dns/zz_gen_czechia.md @@ -0,0 +1,67 @@ +--- +title: "Czechia" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: czechia +dnsprovider: + since: "v4.33.0" + code: "czechia" + url: "https://www.czechia.com/" +--- + + + + + + +Configuration for [Czechia](https://www.czechia.com/). + + + + +- Code: `czechia` +- Since: v4.33.0 + + +Here is an example bash command using the Czechia provider: + +```bash +CZECHIA_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns czechia -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `CZECHIA_TOKEN` | Authorization token | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `CZECHIA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `CZECHIA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `CZECHIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `CZECHIA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://api.czechia.com/swagger/index.html) + + + + diff --git a/docs/content/dns/zz_gen_ddnss.md b/docs/content/dns/zz_gen_ddnss.md new file mode 100644 index 000000000..e159d58b4 --- /dev/null +++ b/docs/content/dns/zz_gen_ddnss.md @@ -0,0 +1,68 @@ +--- +title: "DDnss (DynDNS Service)" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: ddnss +dnsprovider: + since: "v4.32.0" + code: "ddnss" + url: "https://ddnss.de/" +--- + + + + + + +Configuration for [DDnss (DynDNS Service)](https://ddnss.de/). + + + + +- Code: `ddnss` +- Since: v4.32.0 + + +Here is an example bash command using the DDnss (DynDNS Service) provider: + +```bash +DDNSS_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns ddnss -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `DDNSS_KEY` | Update key | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `DDNSS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `DDNSS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `DDNSS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `DDNSS_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | +| `DDNSS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://ddnss.de/info.php) + + + + diff --git a/docs/content/dns/zz_gen_derak.md b/docs/content/dns/zz_gen_derak.md index a5daf76db..c5c8c7bc6 100644 --- a/docs/content/dns/zz_gen_derak.md +++ b/docs/content/dns/zz_gen_derak.md @@ -27,7 +27,7 @@ Here is an example bash command using the Derak Cloud provider: ```bash DERAK_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ -lego --email you@example.com --dns derak -d '*.example.com' -d example.com run +lego --dns derak -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `DERAK_HTTP_TIMEOUT` | API request timeout | -| `DERAK_POLLING_INTERVAL` | Time between DNS propagation check | -| `DERAK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `DERAK_TTL` | The TTL of the TXT record used for the DNS challenge | +| `DERAK_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `DERAK_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | +| `DERAK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `DERAK_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | | `DERAK_WEBSITE_ID` | Force the zone/website ID | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. diff --git a/docs/content/dns/zz_gen_desec.md b/docs/content/dns/zz_gen_desec.md index 45e5fabc6..4dbc713d6 100644 --- a/docs/content/dns/zz_gen_desec.md +++ b/docs/content/dns/zz_gen_desec.md @@ -27,7 +27,7 @@ Here is an example bash command using the deSEC.io provider: ```bash DESEC_TOKEN=x-xxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns desec -d '*.example.com' -d example.com run +lego --dns desec -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `DESEC_HTTP_TIMEOUT` | API request timeout | -| `DESEC_POLLING_INTERVAL` | Time between DNS propagation check | -| `DESEC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `DESEC_TTL` | The TTL of the TXT record used for the DNS challenge | +| `DESEC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `DESEC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) | +| `DESEC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `DESEC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_designate.md b/docs/content/dns/zz_gen_designate.md index cbbdfa557..9703f094d 100644 --- a/docs/content/dns/zz_gen_designate.md +++ b/docs/content/dns/zz_gen_designate.md @@ -28,7 +28,7 @@ Here is an example bash command using the Designate DNSaaS for Openstack provide ```bash # With a `clouds.yaml` OS_CLOUD=my_openstack \ -lego --email you@example.com --dns designate -d '*.example.com' -d example.com run +lego --dns designate -d '*.example.com' -d example.com run # or @@ -37,7 +37,7 @@ OS_REGION_NAME=RegionOne \ OS_PROJECT_ID=23d4522a987d4ab529f722a007c27846 OS_USERNAME=myuser \ OS_PASSWORD=passw0rd \ -lego --email you@example.com --dns designate -d '*.example.com' -d example.com run +lego --dns designate -d '*.example.com' -d example.com run # or @@ -46,7 +46,7 @@ OS_REGION_NAME=RegionOne \ OS_AUTH_TYPE=v3applicationcredential \ OS_APPLICATION_CREDENTIAL_ID=imn74uq0or7dyzz20dwo1ytls4me8dry \ OS_APPLICATION_CREDENTIAL_SECRET=68FuSPSdQqkFQYH5X1OoriEIJOwyLtQ8QSqXZOc9XxFK1A9tzZT6He2PfPw0OMja \ -lego --email you@example.com --dns designate -d '*.example.com' -d example.com run +lego --dns designate -d '*.example.com' -d example.com run ``` @@ -74,9 +74,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `DESIGNATE_POLLING_INTERVAL` | Time between DNS propagation check | -| `DESIGNATE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `DESIGNATE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `DESIGNATE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `DESIGNATE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) | +| `DESIGNATE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 10) | | `DESIGNATE_ZONE_NAME` | The zone name to use in the OpenStack Project to manage TXT records. | | `OS_PROJECT_ID` | Project ID | | `OS_TENANT_NAME` | Tenant name (deprecated see OS_PROJECT_NAME and OS_PROJECT_ID) | diff --git a/docs/content/dns/zz_gen_digitalocean.md b/docs/content/dns/zz_gen_digitalocean.md index 3bf57f59d..4dc43886d 100644 --- a/docs/content/dns/zz_gen_digitalocean.md +++ b/docs/content/dns/zz_gen_digitalocean.md @@ -27,7 +27,7 @@ Here is an example bash command using the Digital Ocean provider: ```bash DO_AUTH_TOKEN=xxxxxx \ -lego --email you@example.com --dns digitalocean -d '*.example.com' -d example.com run +lego --dns digitalocean -d '*.example.com' -d example.com run ``` @@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `DO_API_URL` | The URL of the API | -| `DO_HTTP_TIMEOUT` | API request timeout | -| `DO_POLLING_INTERVAL` | Time between DNS propagation check | -| `DO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `DO_TTL` | The TTL of the TXT record used for the DNS challenge | +| `DO_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `DO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | +| `DO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `DO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 30) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_directadmin.md b/docs/content/dns/zz_gen_directadmin.md index 252c69ccf..1d03dcc4e 100644 --- a/docs/content/dns/zz_gen_directadmin.md +++ b/docs/content/dns/zz_gen_directadmin.md @@ -29,7 +29,7 @@ Here is an example bash command using the DirectAdmin provider: DIRECTADMIN_API_URL="http://example.com:2222" \ DIRECTADMIN_USERNAME=xxxx \ DIRECTADMIN_PASSWORD=yyy \ -lego --email you@example.com --dns directadmin -d '*.example.com' -d example.com run +lego --dns directadmin -d '*.example.com' -d example.com run ``` @@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `DIRECTADMIN_HTTP_TIMEOUT` | API request timeout | -| `DIRECTADMIN_POLLING_INTERVAL` | Time between DNS propagation check | -| `DIRECTADMIN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `DIRECTADMIN_TTL` | The TTL of the TXT record used for the DNS challenge | +| `DIRECTADMIN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `DIRECTADMIN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | +| `DIRECTADMIN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `DIRECTADMIN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 30) | | `DIRECTADMIN_ZONE_NAME` | Zone name used to add the TXT record | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. diff --git a/docs/content/dns/zz_gen_dnsexit.md b/docs/content/dns/zz_gen_dnsexit.md new file mode 100644 index 000000000..aca5357e8 --- /dev/null +++ b/docs/content/dns/zz_gen_dnsexit.md @@ -0,0 +1,67 @@ +--- +title: "DNSExit" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: dnsexit +dnsprovider: + since: "v4.32.0" + code: "dnsexit" + url: "https://dnsexit.com" +--- + + + + + + +Configuration for [DNSExit](https://dnsexit.com). + + + + +- Code: `dnsexit` +- Since: v4.32.0 + + +Here is an example bash command using the DNSExit provider: + +```bash +DNSEXIT_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns dnsexit -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `DNSEXIT_API_KEY` | API key | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `DNSEXIT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `DNSEXIT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `DNSEXIT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | +| `DNSEXIT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://dnsexit.com/dns/dns-api/) + + + + diff --git a/docs/content/dns/zz_gen_dnshomede.md b/docs/content/dns/zz_gen_dnshomede.md index 56825f38d..ca7f83523 100644 --- a/docs/content/dns/zz_gen_dnshomede.md +++ b/docs/content/dns/zz_gen_dnshomede.md @@ -27,10 +27,10 @@ Here is an example bash command using the dnsHome.de provider: ```bash DNSHOMEDE_CREDENTIALS=example.org:password \ -lego --email you@example.com --dns dnshomede -d '*.example.com' -d example.com run +lego --dns dnshomede -d '*.example.com' -d example.com run DNSHOMEDE_CREDENTIALS=my.example.org:password1,demo.example.org:password2 \ -lego --email you@example.com --dns dnshomede -d my.example.org -d demo.example.org +lego --dns dnshomede -d my.example.org -d demo.example.org ``` @@ -50,10 +50,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `DNSHOMEDE_HTTP_TIMEOUT` | API request timeout | -| `DNSHOMEDE_POLLING_INTERVAL` | Time between DNS propagation checks | -| `DNSHOMEDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation; defaults to 300s (5 minutes) | -| `DNSHOMEDE_SEQUENCE_INTERVAL` | Time between sequential requests | +| `DNSHOMEDE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `DNSHOMEDE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 1200) | +| `DNSHOMEDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 2) | +| `DNSHOMEDE_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_dnsimple.md b/docs/content/dns/zz_gen_dnsimple.md index 188d7c895..7799ece88 100644 --- a/docs/content/dns/zz_gen_dnsimple.md +++ b/docs/content/dns/zz_gen_dnsimple.md @@ -27,7 +27,7 @@ Here is an example bash command using the DNSimple provider: ```bash DNSIMPLE_OAUTH_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \ -lego --email you@example.com --dns dnsimple -d '*.example.com' -d example.com run +lego --dns dnsimple -d '*.example.com' -d example.com run ``` @@ -48,9 +48,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `DNSIMPLE_BASE_URL` | API endpoint URL | -| `DNSIMPLE_POLLING_INTERVAL` | Time between DNS propagation check | -| `DNSIMPLE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `DNSIMPLE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `DNSIMPLE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `DNSIMPLE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `DNSIMPLE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_dnsmadeeasy.md b/docs/content/dns/zz_gen_dnsmadeeasy.md index d6f1cb56b..e7f260889 100644 --- a/docs/content/dns/zz_gen_dnsmadeeasy.md +++ b/docs/content/dns/zz_gen_dnsmadeeasy.md @@ -28,7 +28,7 @@ Here is an example bash command using the DNS Made Easy provider: ```bash DNSMADEEASY_API_KEY=xxxxxx \ DNSMADEEASY_API_SECRET=yyyyy \ -lego --email you@example.com --dns dnsmadeeasy -d '*.example.com' -d example.com run +lego --dns dnsmadeeasy -d '*.example.com' -d example.com run ``` @@ -49,11 +49,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `DNSMADEEASY_HTTP_TIMEOUT` | API request timeout | -| `DNSMADEEASY_POLLING_INTERVAL` | Time between DNS propagation check | -| `DNSMADEEASY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `DNSMADEEASY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `DNSMADEEASY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `DNSMADEEASY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `DNSMADEEASY_SANDBOX` | Activate the sandbox (boolean) | -| `DNSMADEEASY_TTL` | The TTL of the TXT record used for the DNS challenge | +| `DNSMADEEASY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_dnspod.md b/docs/content/dns/zz_gen_dnspod.md index 2a654d640..86112a5ce 100644 --- a/docs/content/dns/zz_gen_dnspod.md +++ b/docs/content/dns/zz_gen_dnspod.md @@ -27,7 +27,7 @@ Here is an example bash command using the DNSPod (deprecated) provider: ```bash DNSPOD_API_KEY=xxxxxx \ -lego --email you@example.com --dns dnspod -d '*.example.com' -d example.com run +lego --dns dnspod -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `DNSPOD_HTTP_TIMEOUT` | API request timeout | -| `DNSPOD_POLLING_INTERVAL` | Time between DNS propagation check | -| `DNSPOD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `DNSPOD_TTL` | The TTL of the TXT record used for the DNS challenge | +| `DNSPOD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `DNSPOD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `DNSPOD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `DNSPOD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_dode.md b/docs/content/dns/zz_gen_dode.md index b73fa70df..28eebe5fa 100644 --- a/docs/content/dns/zz_gen_dode.md +++ b/docs/content/dns/zz_gen_dode.md @@ -27,7 +27,7 @@ Here is an example bash command using the Domain Offensive (do.de) provider: ```bash DODE_TOKEN=xxxxxx \ -lego --email you@example.com --dns dode -d '*.example.com' -d example.com run +lego --dns dode -d '*.example.com' -d example.com run ``` @@ -47,11 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `DODE_HTTP_TIMEOUT` | API request timeout | -| `DODE_POLLING_INTERVAL` | Time between DNS propagation check | -| `DODE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `DODE_SEQUENCE_INTERVAL` | Time between sequential requests | -| `DODE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `DODE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `DODE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `DODE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `DODE_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_domeneshop.md b/docs/content/dns/zz_gen_domeneshop.md index 24a19a056..0530ab365 100644 --- a/docs/content/dns/zz_gen_domeneshop.md +++ b/docs/content/dns/zz_gen_domeneshop.md @@ -28,7 +28,7 @@ Here is an example bash command using the Domeneshop provider: ```bash DOMENESHOP_API_TOKEN= \ DOMENESHOP_API_SECRET= \ -lego --email example@example.com --dns domeneshop -d '*.example.com' -d example.com run +lego --dns domeneshop -d '*.example.com' -d example.com run ``` @@ -49,9 +49,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `DOMENESHOP_HTTP_TIMEOUT` | API request timeout | -| `DOMENESHOP_POLLING_INTERVAL` | Time between DNS propagation check | -| `DOMENESHOP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `DOMENESHOP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `DOMENESHOP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) | +| `DOMENESHOP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_dreamhost.md b/docs/content/dns/zz_gen_dreamhost.md index 9d9663971..b9d273099 100644 --- a/docs/content/dns/zz_gen_dreamhost.md +++ b/docs/content/dns/zz_gen_dreamhost.md @@ -27,7 +27,7 @@ Here is an example bash command using the DreamHost provider: ```bash DREAMHOST_API_KEY="YOURAPIKEY" \ -lego --email you@example.com --dns dreamhost -d '*.example.com' -d example.com run +lego --dns dreamhost -d '*.example.com' -d example.com run ``` @@ -47,10 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `DREAMHOST_HTTP_TIMEOUT` | API request timeout | -| `DREAMHOST_POLLING_INTERVAL` | Time between DNS propagation check | -| `DREAMHOST_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `DREAMHOST_TTL` | The TTL of the TXT record used for the DNS challenge | +| `DREAMHOST_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `DREAMHOST_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 60) | +| `DREAMHOST_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_duckdns.md b/docs/content/dns/zz_gen_duckdns.md index 515097c77..8b60780d2 100644 --- a/docs/content/dns/zz_gen_duckdns.md +++ b/docs/content/dns/zz_gen_duckdns.md @@ -27,7 +27,7 @@ Here is an example bash command using the Duck DNS provider: ```bash DUCKDNS_TOKEN=xxxxxx \ -lego --email you@example.com --dns duckdns -d '*.example.com' -d example.com run +lego --dns duckdns -d '*.example.com' -d example.com run ``` @@ -47,11 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `DUCKDNS_HTTP_TIMEOUT` | API request timeout | -| `DUCKDNS_POLLING_INTERVAL` | Time between DNS propagation check | -| `DUCKDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `DUCKDNS_SEQUENCE_INTERVAL` | Time between sequential requests | -| `DUCKDNS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `DUCKDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `DUCKDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `DUCKDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `DUCKDNS_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_dyn.md b/docs/content/dns/zz_gen_dyn.md index 32f902394..e31a90e45 100644 --- a/docs/content/dns/zz_gen_dyn.md +++ b/docs/content/dns/zz_gen_dyn.md @@ -29,7 +29,7 @@ Here is an example bash command using the Dyn provider: DYN_CUSTOMER_NAME=xxxxxx \ DYN_USER_NAME=yyyyy \ DYN_PASSWORD=zzzz \ -lego --email you@example.com --dns dyn -d '*.example.com' -d example.com run +lego --dns dyn -d '*.example.com' -d example.com run ``` @@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `DYN_HTTP_TIMEOUT` | API request timeout | -| `DYN_POLLING_INTERVAL` | Time between DNS propagation check | -| `DYN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `DYN_TTL` | The TTL of the TXT record used for the DNS challenge | +| `DYN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `DYN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `DYN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `DYN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_dyndnsfree.md b/docs/content/dns/zz_gen_dyndnsfree.md new file mode 100644 index 000000000..ea549b4e2 --- /dev/null +++ b/docs/content/dns/zz_gen_dyndnsfree.md @@ -0,0 +1,68 @@ +--- +title: "DynDnsFree.de" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: dyndnsfree +dnsprovider: + since: "v4.23.0" + code: "dyndnsfree" + url: "https://www.dyndnsfree.de" +--- + + + + + + +Configuration for [DynDnsFree.de](https://www.dyndnsfree.de). + + + + +- Code: `dyndnsfree` +- Since: v4.23.0 + + +Here is an example bash command using the DynDnsFree.de provider: + +```bash +DYNDNSFREE_USERNAME="xxx" \ +DYNDNSFREE_PASSWORD="yyy" \ +lego --dns dyndnsfree -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `DYNDNSFREE_PASSWORD` | Password | +| `DYNDNSFREE_USERNAME` | Username | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `DYNDNSFREE_HTTP_TIMEOUT` | Request timeout in seconds (Default: 30) | +| `DYNDNSFREE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `DYNDNSFREE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://www.dyndnsfree.de/user/hilfe.php?hsm=2) + + + + diff --git a/docs/content/dns/zz_gen_dynu.md b/docs/content/dns/zz_gen_dynu.md index d59fa23f5..a1f3e762e 100644 --- a/docs/content/dns/zz_gen_dynu.md +++ b/docs/content/dns/zz_gen_dynu.md @@ -27,7 +27,7 @@ Here is an example bash command using the Dynu provider: ```bash DYNU_API_KEY=1234567890abcdefghijklmnopqrstuvwxyz \ -lego --email you@example.com --dns dynu -d '*.example.com' -d example.com run +lego --dns dynu -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `DYNU_HTTP_TIMEOUT` | API request timeout | -| `DYNU_POLLING_INTERVAL` | Time between DNS propagation check | -| `DYNU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `DYNU_TTL` | The TTL of the TXT record used for the DNS challenge | +| `DYNU_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `DYNU_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `DYNU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 180) | +| `DYNU_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_easydns.md b/docs/content/dns/zz_gen_easydns.md index f4c44164c..12f69e09c 100644 --- a/docs/content/dns/zz_gen_easydns.md +++ b/docs/content/dns/zz_gen_easydns.md @@ -28,7 +28,7 @@ Here is an example bash command using the EasyDNS provider: ```bash EASYDNS_TOKEN=xxx \ EASYDNS_KEY=yyy \ -lego --email you@example.com --dns easydns -d '*.example.com' -d example.com run +lego --dns easydns -d '*.example.com' -d example.com run ``` @@ -50,11 +50,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `EASYDNS_ENDPOINT` | The endpoint URL of the API Server | -| `EASYDNS_HTTP_TIMEOUT` | API request timeout | -| `EASYDNS_POLLING_INTERVAL` | Time between DNS propagation check | -| `EASYDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `EASYDNS_SEQUENCE_INTERVAL` | Time between sequential requests | -| `EASYDNS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `EASYDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `EASYDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `EASYDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `EASYDNS_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | +| `EASYDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_edgecenter.md b/docs/content/dns/zz_gen_edgecenter.md new file mode 100644 index 000000000..1fd9fe5fa --- /dev/null +++ b/docs/content/dns/zz_gen_edgecenter.md @@ -0,0 +1,67 @@ +--- +title: "EdgeCenter" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: edgecenter +dnsprovider: + since: "v4.29.0" + code: "edgecenter" + url: "https://edgecenter.ru/dns" +--- + + + + + + +Configuration for [EdgeCenter](https://edgecenter.ru/dns). + + + + +- Code: `edgecenter` +- Since: v4.29.0 + + +Here is an example bash command using the EdgeCenter provider: + +```bash +EDGECENTER_PERMANENT_API_TOKEN=xxxxx \ +lego --dns edgecenter -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `EDGECENTER_PERMANENT_API_TOKEN` | Permanent API token (https://edgecenter.ru/blog/permanent-api-token-explained/) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `EDGECENTER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `EDGECENTER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) | +| `EDGECENTER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 360) | +| `EDGECENTER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://apidocs.edgecenter.ru/dns) + + + + diff --git a/docs/content/dns/zz_gen_edgedns.md b/docs/content/dns/zz_gen_edgedns.md index 3ba5fffea..31b191168 100644 --- a/docs/content/dns/zz_gen_edgedns.md +++ b/docs/content/dns/zz_gen_edgedns.md @@ -30,7 +30,7 @@ AKAMAI_CLIENT_SECRET=abcdefghijklmnopqrstuvwxyz1234567890ABCDEFG= \ AKAMAI_CLIENT_TOKEN=akab-mnbvcxzlkjhgfdsapoiuytrewq1234567 \ AKAMAI_HOST=akab-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.luna.akamaiapis.net \ AKAMAI_ACCESS_TOKEN=akab-1234567890qwerty-asdfghjklzxcvtnu \ -lego --email you@example.com --dns edgedns -d '*.example.com' -d example.com run +lego --dns edgedns -d '*.example.com' -d example.com run ``` @@ -55,9 +55,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `AKAMAI_POLLING_INTERVAL` | Time between DNS propagation check. Default: 15 seconds | -| `AKAMAI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation. Default: 3 minutes | -| `AKAMAI_TTL` | The TTL of the TXT record used for the DNS challenge | +| `AKAMAI_ACCOUNT_SWITCH_KEY` | Target account ID when the DNS zone and credentials belong to different accounts | +| `AKAMAI_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 15) | +| `AKAMAI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 180) | +| `AKAMAI_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). @@ -88,6 +89,7 @@ See also: - [.edgerc Format](https://developer.akamai.com/legacy/introduction/Conf_Client.html#edgercformat) - [API Client Authentication](https://developer.akamai.com/legacy/introduction/Client_Auth.html) - [Config from Env](https://github.com/akamai/AkamaiOPEN-edgegrid-golang/blob/master/pkg/edgegrid/config.go#L118) +- [Manage many accounts](https://techdocs.akamai.com/developer/docs/manage-many-accounts-with-one-api-client) diff --git a/docs/content/dns/zz_gen_edgeone.md b/docs/content/dns/zz_gen_edgeone.md new file mode 100644 index 000000000..ba5de5ba2 --- /dev/null +++ b/docs/content/dns/zz_gen_edgeone.md @@ -0,0 +1,73 @@ +--- +title: "Tencent EdgeOne" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: edgeone +dnsprovider: + since: "v4.26.0" + code: "edgeone" + url: "https://edgeone.ai" +--- + + + + + + +Configuration for [Tencent EdgeOne](https://edgeone.ai). + + + + +- Code: `edgeone` +- Since: v4.26.0 + + +Here is an example bash command using the Tencent EdgeOne provider: + +```bash +EDGEONE_SECRET_ID=abcdefghijklmnopqrstuvwx \ +EDGEONE_SECRET_KEY=your-secret-key \ +lego --dns edgeone -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `EDGEONE_SECRET_ID` | Access key ID | +| `EDGEONE_SECRET_KEY` | Access Key secret | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `EDGEONE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `EDGEONE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) | +| `EDGEONE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 1200) | +| `EDGEONE_REGION` | Region | +| `EDGEONE_SESSION_TOKEN` | Access Key token | +| `EDGEONE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | +| `EDGEONE_ZONES_MAPPING` | Mapping between DNS zones and site IDs. (ex: 'example.org:id1,example.com:id2') | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://edgeone.ai/document/50454#dns-record-apis) +- [Go client](https://github.com/tencentcloud/tencentcloud-sdk-go) + + + + diff --git a/docs/content/dns/zz_gen_efficientip.md b/docs/content/dns/zz_gen_efficientip.md index cfdfb9bba..acca3ebb7 100644 --- a/docs/content/dns/zz_gen_efficientip.md +++ b/docs/content/dns/zz_gen_efficientip.md @@ -30,7 +30,7 @@ EFFICIENTIP_USERNAME="user" \ EFFICIENTIP_PASSWORD="secret" \ EFFICIENTIP_HOSTNAME="ipam.example.org" \ EFFICIENTIP_DNS_NAME="dns.smart" \ -lego --email you@example.com --dns efficientip -d '*.example.com' -d example.com run +lego --dns efficientip -d '*.example.com' -d example.com run ``` @@ -53,11 +53,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `EFFICIENTIP_HTTP_TIMEOUT` | API request timeout | +| `EFFICIENTIP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `EFFICIENTIP_INSECURE_SKIP_VERIFY` | Whether or not to verify EfficientIP API certificate | -| `EFFICIENTIP_POLLING_INTERVAL` | Time between DNS propagation check | -| `EFFICIENTIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `EFFICIENTIP_TTL` | The TTL of the TXT record used for the DNS challenge | +| `EFFICIENTIP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `EFFICIENTIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `EFFICIENTIP_VIEW_NAME` | View name (ex: external) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. diff --git a/docs/content/dns/zz_gen_epik.md b/docs/content/dns/zz_gen_epik.md index 861efb640..a7fc029d3 100644 --- a/docs/content/dns/zz_gen_epik.md +++ b/docs/content/dns/zz_gen_epik.md @@ -27,7 +27,7 @@ Here is an example bash command using the Epik provider: ```bash EPIK_SIGNATURE=xxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns epik -d '*.example.com' -d example.com run +lego --dns epik -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `EPIK_HTTP_TIMEOUT` | API request timeout | -| `EPIK_POLLING_INTERVAL` | Time between DNS propagation check | -| `EPIK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `EPIK_TTL` | The TTL of the TXT record used for the DNS challenge | +| `EPIK_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `EPIK_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `EPIK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `EPIK_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_eurodns.md b/docs/content/dns/zz_gen_eurodns.md new file mode 100644 index 000000000..cb5a0418d --- /dev/null +++ b/docs/content/dns/zz_gen_eurodns.md @@ -0,0 +1,69 @@ +--- +title: "EuroDNS" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: eurodns +dnsprovider: + since: "v4.33.0" + code: "eurodns" + url: "https://www.eurodns.com/" +--- + + + + + + +Configuration for [EuroDNS](https://www.eurodns.com/). + + + + +- Code: `eurodns` +- Since: v4.33.0 + + +Here is an example bash command using the EuroDNS provider: + +```bash +EURODNS_APP_ID="xxx" \ +EURODNS_API_KEY="yyy" \ +lego --dns eurodns -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `EURODNS_API_KEY` | API key | +| `EURODNS_APP_ID` | Application ID | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `EURODNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `EURODNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `EURODNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `EURODNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://docapi.eurodns.com/) + + + + diff --git a/docs/content/dns/zz_gen_excedo.md b/docs/content/dns/zz_gen_excedo.md new file mode 100644 index 000000000..456e6f60a --- /dev/null +++ b/docs/content/dns/zz_gen_excedo.md @@ -0,0 +1,69 @@ +--- +title: "Excedo" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: excedo +dnsprovider: + since: "v4.33.0" + code: "excedo" + url: "https://excedo.se/" +--- + + + + + + +Configuration for [Excedo](https://excedo.se/). + + + + +- Code: `excedo` +- Since: v4.33.0 + + +Here is an example bash command using the Excedo provider: + +```bash +EXCEDO_API_KEY=your-api-key \ +EXCEDO_API_URL=your-base-url \ +lego --dns excedo -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `EXCEDO_API_KEY` | API key | +| `EXCEDO_API_URL` | API base URL | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `EXCEDO_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `EXCEDO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `EXCEDO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | +| `EXCEDO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](none) + + + + diff --git a/docs/content/dns/zz_gen_exec.md b/docs/content/dns/zz_gen_exec.md index f2f5f9619..ad2e6906e 100644 --- a/docs/content/dns/zz_gen_exec.md +++ b/docs/content/dns/zz_gen_exec.md @@ -26,7 +26,7 @@ Here is an example bash command using the External program provider: ```bash EXEC_PATH=/the/path/to/myscript.sh \ -lego --email you@example.com --dns exec -d '*.example.com' -d example.com run +lego --dns exec -d '*.example.com' -d example.com run ``` @@ -43,11 +43,11 @@ lego --email you@example.com --dns exec -d '*.example.com' -d example.com run ## Additional Configuration -| Environment Variable Name | Description | -|----------------------------|-------------------------------------------| -| `EXEC_POLLING_INTERVAL` | Time between DNS propagation check. | -| `EXEC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation. | -| `EXEC_SEQUENCE_INTERVAL` | Time between sequential requests. | +| Environment Variable Name | Description | +|----------------------------|--------------------------------------------------------------------| +| `EXEC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 3). | +| `EXEC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60). | +| `EXEC_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60). | ## Description @@ -61,7 +61,7 @@ For example, requesting a certificate for the domain 'my.example.org' can be ach ```bash EXEC_PATH=./update-dns.sh \ -lego --email you@example.com --dns exec --d my.example.org run +lego --dns exec --d my.example.org run ``` It will then call the program './update-dns.sh' with like this: @@ -81,7 +81,7 @@ If you want to use the raw domain, token, and keyAuth values with your program, ```bash EXEC_MODE=RAW \ EXEC_PATH=./update-dns.sh \ -lego --email you@example.com --dns exec -d my.example.org run +lego --dns exec -d my.example.org run ``` It will then call the program `./update-dns.sh` like this: diff --git a/docs/content/dns/zz_gen_exoscale.md b/docs/content/dns/zz_gen_exoscale.md index ffd3da1e4..e599d6487 100644 --- a/docs/content/dns/zz_gen_exoscale.md +++ b/docs/content/dns/zz_gen_exoscale.md @@ -28,7 +28,7 @@ Here is an example bash command using the Exoscale provider: ```bash EXOSCALE_API_KEY=abcdefghijklmnopqrstuvwx \ EXOSCALE_API_SECRET=xxxxxxx \ -lego --email you@example.com --dns exoscale -d '*.example.com' -d example.com run +lego --dns exoscale -d '*.example.com' -d example.com run ``` @@ -50,10 +50,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `EXOSCALE_ENDPOINT` | API endpoint URL | -| `EXOSCALE_HTTP_TIMEOUT` | API request timeout | -| `EXOSCALE_POLLING_INTERVAL` | Time between DNS propagation check | -| `EXOSCALE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `EXOSCALE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `EXOSCALE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | +| `EXOSCALE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `EXOSCALE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `EXOSCALE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_f5xc.md b/docs/content/dns/zz_gen_f5xc.md new file mode 100644 index 000000000..0fd8fe58a --- /dev/null +++ b/docs/content/dns/zz_gen_f5xc.md @@ -0,0 +1,72 @@ +--- +title: "F5 XC" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: f5xc +dnsprovider: + since: "v4.23.0" + code: "f5xc" + url: "https://www.f5.com/products/distributed-cloud-services" +--- + + + + + + +Configuration for [F5 XC](https://www.f5.com/products/distributed-cloud-services). + + + + +- Code: `f5xc` +- Since: v4.23.0 + + +Here is an example bash command using the F5 XC provider: + +```bash +F5XC_API_TOKEN="xxx" \ +F5XC_TENANT_NAME="yyy" \ +F5XC_GROUP_NAME="zzz" \ +lego --dns f5xc -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `F5XC_API_TOKEN` | API token | +| `F5XC_GROUP_NAME` | Group name | +| `F5XC_TENANT_NAME` | XC Tenant shortname | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `F5XC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `F5XC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `F5XC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `F5XC_SERVER` | Server domain (Default: console.ves.volterra.io) | +| `F5XC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset) + + + + diff --git a/docs/content/dns/zz_gen_freemyip.md b/docs/content/dns/zz_gen_freemyip.md index 421361205..215f8eb84 100644 --- a/docs/content/dns/zz_gen_freemyip.md +++ b/docs/content/dns/zz_gen_freemyip.md @@ -27,7 +27,7 @@ Here is an example bash command using the freemyip.com provider: ```bash FREEMYIP_TOKEN=xxxxxx \ -lego --email you@example.com --dns freemyip -d '*.example.com' -d example.com run +lego --dns freemyip -d '*.example.com' -d example.com run ``` @@ -47,11 +47,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `FREEMYIP_HTTP_TIMEOUT` | API request timeout | -| `FREEMYIP_POLLING_INTERVAL` | Time between DNS propagation check | -| `FREEMYIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `FREEMYIP_SEQUENCE_INTERVAL` | Time between sequential requests | -| `FREEMYIP_TTL` | The TTL of the TXT record used for the DNS challenge | +| `FREEMYIP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `FREEMYIP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `FREEMYIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `FREEMYIP_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | +| `FREEMYIP_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_gandi.md b/docs/content/dns/zz_gen_gandi.md index fa7ae6fe0..b02d97819 100644 --- a/docs/content/dns/zz_gen_gandi.md +++ b/docs/content/dns/zz_gen_gandi.md @@ -27,7 +27,7 @@ Here is an example bash command using the Gandi provider: ```bash GANDI_API_KEY=abcdefghijklmnopqrstuvwx \ -lego --email you@example.com --dns gandi -d '*.example.com' -d example.com run +lego --dns gandi -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `GANDI_HTTP_TIMEOUT` | API request timeout | -| `GANDI_POLLING_INTERVAL` | Time between DNS propagation check | -| `GANDI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `GANDI_TTL` | The TTL of the TXT record used for the DNS challenge | +| `GANDI_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | +| `GANDI_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 60) | +| `GANDI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 2400) | +| `GANDI_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_gandiv5.md b/docs/content/dns/zz_gen_gandiv5.md index c3f0e2d20..78824abbe 100644 --- a/docs/content/dns/zz_gen_gandiv5.md +++ b/docs/content/dns/zz_gen_gandiv5.md @@ -27,7 +27,7 @@ Here is an example bash command using the Gandi Live DNS (v5) provider: ```bash GANDIV5_PERSONAL_ACCESS_TOKEN=abcdefghijklmnopqrstuvwx \ -lego --email you@example.com --dns gandiv5 -d '*.example.com' -d example.com run +lego --dns gandiv5 -d '*.example.com' -d example.com run ``` @@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `GANDIV5_HTTP_TIMEOUT` | API request timeout | -| `GANDIV5_POLLING_INTERVAL` | Time between DNS propagation check | -| `GANDIV5_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `GANDIV5_TTL` | The TTL of the TXT record used for the DNS challenge | +| `GANDIV5_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `GANDIV5_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) | +| `GANDIV5_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 1200) | +| `GANDIV5_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_gcloud.md b/docs/content/dns/zz_gen_gcloud.md index 556bffe3d..64acc1d1e 100644 --- a/docs/content/dns/zz_gen_gcloud.md +++ b/docs/content/dns/zz_gen_gcloud.md @@ -26,9 +26,21 @@ Configuration for [Google Cloud](https://cloud.google.com). Here is an example bash command using the Google Cloud provider: ```bash +# Using a service account file GCE_PROJECT="gc-project-id" \ GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file.json" \ -lego --email you@email.com --dns gcloud -d '*.example.com' -d example.com run +lego --dns gcloud -d '*.example.com' -d example.com run + +# Using default credentials with impersonation +GCE_PROJECT="gc-project-id" \ +GCE_IMPERSONATE_SERVICE_ACCOUNT="target-sa@gc-project-id.iam.gserviceaccount.com" \ +lego --dns gcloud -d '*.example.com' -d example.com run + +# Using service account key with impersonation +GCE_PROJECT="gc-project-id" \ +GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file.json" \ +GCE_IMPERSONATE_SERVICE_ACCOUNT="target-sa@gc-project-id.iam.gserviceaccount.com" \ +lego --dns gcloud -d '*.example.com' -d example.com run ``` @@ -52,14 +64,20 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `GCE_ALLOW_PRIVATE_ZONE` | Allows requested domain to be in private DNS zone, works only with a private ACME server (by default: false) | -| `GCE_POLLING_INTERVAL` | Time between DNS propagation check | -| `GCE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `GCE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `GCE_IMPERSONATE_SERVICE_ACCOUNT` | Service account email to impersonate | +| `GCE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | +| `GCE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 180) | +| `GCE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | | `GCE_ZONE_ID` | Allows to skip the automatic detection of the zone | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). +Supports service account impersonation to access Google Cloud DNS resources across different projects or with restricted permissions. + +When using impersonation, the source service account must have: +1. The "Service Account Token Creator" role on the source service account +2. The "https://www.googleapis.com/auth/cloud-platform" scope diff --git a/docs/content/dns/zz_gen_gcore.md b/docs/content/dns/zz_gen_gcore.md index 7dbb3cec8..21a7ee9b1 100644 --- a/docs/content/dns/zz_gen_gcore.md +++ b/docs/content/dns/zz_gen_gcore.md @@ -27,7 +27,7 @@ Here is an example bash command using the G-Core provider: ```bash GCORE_PERMANENT_API_TOKEN=xxxxx \ -lego --email you@example.com --dns gcore -d '*.example.com' -d example.com run +lego --dns gcore -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `GCORE_HTTP_TIMEOUT` | API request timeout | -| `GCORE_POLLING_INTERVAL` | Time between DNS propagation check | -| `GCORE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `GCORE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `GCORE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `GCORE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) | +| `GCORE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 360) | +| `GCORE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_gigahostno.md b/docs/content/dns/zz_gen_gigahostno.md new file mode 100644 index 000000000..a59ffc401 --- /dev/null +++ b/docs/content/dns/zz_gen_gigahostno.md @@ -0,0 +1,70 @@ +--- +title: "Gigahost.no" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: gigahostno +dnsprovider: + since: "v4.29.0" + code: "gigahostno" + url: "https://gigahost.no/" +--- + + + + + + +Configuration for [Gigahost.no](https://gigahost.no/). + + + + +- Code: `gigahostno` +- Since: v4.29.0 + + +Here is an example bash command using the Gigahost.no provider: + +```bash +GIGAHOSTNO_USERNAME="xxxxxxxxxxxxxxxxxxxxx" \ +GIGAHOSTNO_PASSWORD="yyyyyyyyyyyyyyyyyyyyy" \ +lego --dns gigahostno -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `GIGAHOSTNO_PASSWORD` | Password | +| `GIGAHOSTNO_USERNAME` | Username | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `GIGAHOSTNO_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `GIGAHOSTNO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `GIGAHOSTNO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `GIGAHOSTNO_SECRET` | TOTP secret | +| `GIGAHOSTNO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://gigahost.no/api-dokumentasjon) + + + + diff --git a/docs/content/dns/zz_gen_glesys.md b/docs/content/dns/zz_gen_glesys.md index e49209d85..2d2608330 100644 --- a/docs/content/dns/zz_gen_glesys.md +++ b/docs/content/dns/zz_gen_glesys.md @@ -28,7 +28,7 @@ Here is an example bash command using the Glesys provider: ```bash GLESYS_API_USER=xxxxx \ GLESYS_API_KEY=yyyyy \ -lego --email you@example.com --dns glesys -d '*.example.com' -d example.com run +lego --dns glesys -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `GLESYS_HTTP_TIMEOUT` | API request timeout | -| `GLESYS_POLLING_INTERVAL` | Time between DNS propagation check | -| `GLESYS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `GLESYS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `GLESYS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `GLESYS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) | +| `GLESYS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 1200) | +| `GLESYS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_godaddy.md b/docs/content/dns/zz_gen_godaddy.md index 9852a00d0..bc51cd69b 100644 --- a/docs/content/dns/zz_gen_godaddy.md +++ b/docs/content/dns/zz_gen_godaddy.md @@ -28,7 +28,7 @@ Here is an example bash command using the Go Daddy provider: ```bash GODADDY_API_KEY=xxxxxxxx \ GODADDY_API_SECRET=yyyyyyyy \ -lego --email you@example.com --dns godaddy -d '*.example.com' -d example.com run +lego --dns godaddy -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `GODADDY_HTTP_TIMEOUT` | API request timeout | -| `GODADDY_POLLING_INTERVAL` | Time between DNS propagation check | -| `GODADDY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `GODADDY_TTL` | The TTL of the TXT record used for the DNS challenge | +| `GODADDY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `GODADDY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `GODADDY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `GODADDY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_googledomains.md b/docs/content/dns/zz_gen_googledomains.md index a7ccb031e..2421184c0 100644 --- a/docs/content/dns/zz_gen_googledomains.md +++ b/docs/content/dns/zz_gen_googledomains.md @@ -6,15 +6,15 @@ slug: googledomains dnsprovider: since: "v4.11.0" code: "googledomains" - url: "https://domains.google" + url: "https://github.com/go-acme/lego/issues/2553" --- +The Google Domains DNS provider has shut down. -Configuration for [Google Domains](https://domains.google). @@ -27,7 +27,7 @@ Here is an example bash command using the Google Domains provider: ```bash GOOGLE_DOMAINS_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns googledomains -d '*.example.com' -d example.com run +lego --dns googledomains -d '*.example.com' -d example.com run ``` @@ -47,9 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `GOOGLE_DOMAINS_HTTP_TIMEOUT` | API request timeout | -| `GOOGLE_DOMAINS_POLLING_INTERVAL` | Time between DNS propagation check | -| `GOOGLE_DOMAINS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `GOOGLE_DOMAINS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `GOOGLE_DOMAINS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `GOOGLE_DOMAINS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_gravity.md b/docs/content/dns/zz_gen_gravity.md new file mode 100644 index 000000000..654ad8424 --- /dev/null +++ b/docs/content/dns/zz_gen_gravity.md @@ -0,0 +1,71 @@ +--- +title: "Gravity" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: gravity +dnsprovider: + since: "v4.30.0" + code: "gravity" + url: "https://gravity.beryju.io/" +--- + + + + + + +Configuration for [Gravity](https://gravity.beryju.io/). + + + + +- Code: `gravity` +- Since: v4.30.0 + + +Here is an example bash command using the Gravity provider: + +```bash +GRAVITY_SERVER_URL="https://example.org:1234" \ +GRAVITY_USERNAME="xxxxxxxxxxxxxxxxxxxxx" \ +GRAVITY_PASSWORD="yyyyyyyyyyyyyyyyyyyyy" \ +lego --dns gravity -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `GRAVITY_PASSWORD` | Password | +| `GRAVITY_SERVER_URL` | URL of the server | +| `GRAVITY_USERNAME` | Username | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `GRAVITY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `GRAVITY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `GRAVITY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `GRAVITY_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 1) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://gravity.beryju.io/docs/api/reference/) + + + + diff --git a/docs/content/dns/zz_gen_hetzner.md b/docs/content/dns/zz_gen_hetzner.md index 1e28e4445..4e81bd4d9 100644 --- a/docs/content/dns/zz_gen_hetzner.md +++ b/docs/content/dns/zz_gen_hetzner.md @@ -26,8 +26,8 @@ Configuration for [Hetzner](https://hetzner.com). Here is an example bash command using the Hetzner provider: ```bash -HETZNER_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ -lego --email you@example.com --dns hetzner -d '*.example.com' -d example.com run +HETZNER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns hetzner -d '*.example.com' -d example.com run ``` @@ -37,7 +37,7 @@ lego --email you@example.com --dns hetzner -d '*.example.com' -d example.com run | Environment Variable Name | Description | |-----------------------|-------------| -| `HETZNER_API_KEY` | API key | +| `HETZNER_API_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `HETZNER_HTTP_TIMEOUT` | API request timeout | -| `HETZNER_POLLING_INTERVAL` | Time between DNS propagation check | -| `HETZNER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `HETZNER_TTL` | The TTL of the TXT record used for the DNS challenge | +| `HETZNER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `HETZNER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `HETZNER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `HETZNER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). @@ -60,7 +60,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information -- [API documentation](https://dns.hetzner.com/api-docs) +- [API documentation](https://docs.hetzner.cloud/reference/cloud#dns) diff --git a/docs/content/dns/zz_gen_hostingde.md b/docs/content/dns/zz_gen_hostingde.md index b2e575c4c..4a66fe0f1 100644 --- a/docs/content/dns/zz_gen_hostingde.md +++ b/docs/content/dns/zz_gen_hostingde.md @@ -27,7 +27,7 @@ Here is an example bash command using the Hosting.de provider: ```bash HOSTINGDE_API_KEY=xxxxxxxx \ -lego --email you@example.com --dns hostingde -d '*.example.com' -d example.com run +lego --dns hostingde -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `HOSTINGDE_HTTP_TIMEOUT` | API request timeout | -| `HOSTINGDE_POLLING_INTERVAL` | Time between DNS propagation check | -| `HOSTINGDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `HOSTINGDE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `HOSTINGDE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `HOSTINGDE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `HOSTINGDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `HOSTINGDE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | | `HOSTINGDE_ZONE_NAME` | Zone name in ACE format | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. diff --git a/docs/content/dns/zz_gen_hostinger.md b/docs/content/dns/zz_gen_hostinger.md new file mode 100644 index 000000000..c05b3f003 --- /dev/null +++ b/docs/content/dns/zz_gen_hostinger.md @@ -0,0 +1,67 @@ +--- +title: "Hostinger" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: hostinger +dnsprovider: + since: "v4.27.0" + code: "hostinger" + url: "https://www.hostinger.com/" +--- + + + + + + +Configuration for [Hostinger](https://www.hostinger.com/). + + + + +- Code: `hostinger` +- Since: v4.27.0 + + +Here is an example bash command using the Hostinger provider: + +```bash +HOSTINGER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns hostinger -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `HOSTINGER_API_TOKEN` | API Token | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `HOSTINGER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `HOSTINGER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `HOSTINGER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `HOSTINGER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://developers.hostinger.com/#tag/dns-zone) + + + + diff --git a/docs/content/dns/zz_gen_hostingnl.md b/docs/content/dns/zz_gen_hostingnl.md new file mode 100644 index 000000000..09cb69b47 --- /dev/null +++ b/docs/content/dns/zz_gen_hostingnl.md @@ -0,0 +1,67 @@ +--- +title: "Hosting.nl" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: hostingnl +dnsprovider: + since: "v4.30.0" + code: "hostingnl" + url: "https://hosting.nl" +--- + + + + + + +Configuration for [Hosting.nl](https://hosting.nl). + + + + +- Code: `hostingnl` +- Since: v4.30.0 + + +Here is an example bash command using the Hosting.nl provider: + +```bash +HOSTINGNL_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns hostingnl -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `HOSTINGNL_API_KEY` | The API key | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `HOSTINGNL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `HOSTINGNL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `HOSTINGNL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `HOSTINGNL_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://api.hosting.nl/api/documentation) + + + + diff --git a/docs/content/dns/zz_gen_hosttech.md b/docs/content/dns/zz_gen_hosttech.md index e2881c4fa..9435cc562 100644 --- a/docs/content/dns/zz_gen_hosttech.md +++ b/docs/content/dns/zz_gen_hosttech.md @@ -27,7 +27,7 @@ Here is an example bash command using the Hosttech provider: ```bash HOSTTECH_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns hosttech -d '*.example.com' -d example.com run +lego --dns hosttech -d '*.example.com' -d example.com run ``` @@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `HOSTTECH_HTTP_TIMEOUT` | API request timeout | -| `HOSTTECH_POLLING_INTERVAL` | Time between DNS propagation check | -| `HOSTTECH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `HOSTTECH_TTL` | The TTL of the TXT record used for the DNS challenge | +| `HOSTTECH_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `HOSTTECH_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `HOSTTECH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `HOSTTECH_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_httpnet.md b/docs/content/dns/zz_gen_httpnet.md index 8e333992f..862909697 100644 --- a/docs/content/dns/zz_gen_httpnet.md +++ b/docs/content/dns/zz_gen_httpnet.md @@ -27,7 +27,7 @@ Here is an example bash command using the http.net provider: ```bash HTTPNET_API_KEY=xxxxxxxx \ -lego --email you@example.com --dns httpnet -d '*.example.com' -d example.com run +lego --dns httpnet -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `HTTPNET_HTTP_TIMEOUT` | API request timeout | -| `HTTPNET_POLLING_INTERVAL` | Time between DNS propagation check | -| `HTTPNET_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `HTTPNET_TTL` | The TTL of the TXT record used for the DNS challenge | +| `HTTPNET_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `HTTPNET_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `HTTPNET_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `HTTPNET_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | | `HTTPNET_ZONE_NAME` | Zone name in ACE format | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. diff --git a/docs/content/dns/zz_gen_httpreq.md b/docs/content/dns/zz_gen_httpreq.md index 81a761d4c..7f6a8d576 100644 --- a/docs/content/dns/zz_gen_httpreq.md +++ b/docs/content/dns/zz_gen_httpreq.md @@ -27,7 +27,7 @@ Here is an example bash command using the HTTP request provider: ```bash HTTPREQ_ENDPOINT=http://my.server.com:9090 \ -lego --email you@example.com --dns httpreq -d '*.example.com' -d example.com run +lego --dns httpreq -d '*.example.com' -d example.com run ``` @@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `HTTPREQ_HTTP_TIMEOUT` | API request timeout | +| `HTTPREQ_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `HTTPREQ_PASSWORD` | Basic authentication password | -| `HTTPREQ_POLLING_INTERVAL` | Time between DNS propagation check | -| `HTTPREQ_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `HTTPREQ_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `HTTPREQ_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `HTTPREQ_USERNAME` | Basic authentication username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. diff --git a/docs/content/dns/zz_gen_huaweicloud.md b/docs/content/dns/zz_gen_huaweicloud.md index d5911eff6..46d121265 100644 --- a/docs/content/dns/zz_gen_huaweicloud.md +++ b/docs/content/dns/zz_gen_huaweicloud.md @@ -29,7 +29,7 @@ Here is an example bash command using the Huawei Cloud provider: HUAWEICLOUD_ACCESS_KEY_ID=your-access-key-id \ HUAWEICLOUD_SECRET_ACCESS_KEY=your-secret-access-key \ HUAWEICLOUD_REGION=cn-south-1 \ -lego --email you@example.com --dns huaweicloud -d '*.example.com' -d example.com run +lego --dns huaweicloud -d '*.example.com' -d example.com run ``` @@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `HUAWEICLOUD_HTTP_TIMEOUT` | API request timeout | -| `HUAWEICLOUD_POLLING_INTERVAL` | Time between DNS propagation check | -| `HUAWEICLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `HUAWEICLOUD_TTL` | The TTL of the TXT record used for the DNS challenge | +| `HUAWEICLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `HUAWEICLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `HUAWEICLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `HUAWEICLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_hurricane.md b/docs/content/dns/zz_gen_hurricane.md index 385e6501b..0c195d19c 100644 --- a/docs/content/dns/zz_gen_hurricane.md +++ b/docs/content/dns/zz_gen_hurricane.md @@ -27,10 +27,10 @@ Here is an example bash command using the Hurricane Electric DNS provider: ```bash HURRICANE_TOKENS=example.org:token \ -lego --email you@example.com --dns hurricane -d '*.example.com' -d example.com run +lego --dns hurricane -d '*.example.com' -d example.com run HURRICANE_TOKENS=my.example.org:token1,demo.example.org:token2 \ -lego --email you@example.com --dns hurricane -d my.example.org -d demo.example.org +lego --dns hurricane -d my.example.org -d demo.example.org ``` @@ -50,10 +50,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `HURRICANE_HTTP_TIMEOUT` | API request timeout | -| `HURRICANE_POLLING_INTERVAL` | Time between DNS propagation checks | -| `HURRICANE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation; defaults to 300s (5 minutes) | -| `HURRICANE_SEQUENCE_INTERVAL` | Time between sequential requests | +| `HURRICANE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `HURRICANE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `HURRICANE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation (Default: 300) | +| `HURRICANE_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_hyperone.md b/docs/content/dns/zz_gen_hyperone.md index b533de5d5..bc496f7bc 100644 --- a/docs/content/dns/zz_gen_hyperone.md +++ b/docs/content/dns/zz_gen_hyperone.md @@ -26,7 +26,7 @@ Configuration for [HyperOne](https://www.hyperone.com). Here is an example bash command using the HyperOne provider: ```bash -lego --email you@example.com --dns hyperone -d '*.example.com' -d example.com run +lego --dns hyperone -d '*.example.com' -d example.com run ``` @@ -39,11 +39,12 @@ lego --email you@example.com --dns hyperone -d '*.example.com' -d example.com ru | Environment Variable Name | Description | |--------------------------------|-------------| | `HYPERONE_API_URL` | Allows to pass custom API Endpoint to be used in the challenge (default https://api.hyperone.com/v2) | +| `HYPERONE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `HYPERONE_LOCATION_ID` | Specifies location (region) to be used in API calls. (default pl-waw-1) | | `HYPERONE_PASSPORT_LOCATION` | Allows to pass custom passport file location (default ~/.h1/passport.json) | -| `HYPERONE_POLLING_INTERVAL` | Time between DNS propagation check | -| `HYPERONE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `HYPERONE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `HYPERONE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 60) | +| `HYPERONE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 2) | +| `HYPERONE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_ibmcloud.md b/docs/content/dns/zz_gen_ibmcloud.md index 365377d2b..c5a48d2ad 100644 --- a/docs/content/dns/zz_gen_ibmcloud.md +++ b/docs/content/dns/zz_gen_ibmcloud.md @@ -28,7 +28,7 @@ Here is an example bash command using the IBM Cloud (SoftLayer) provider: ```bash SOFTLAYER_USERNAME=xxxxx \ SOFTLAYER_API_KEY=yyyyy \ -lego --email you@example.com --dns ibmcloud -d '*.example.com' -d example.com run +lego --dns ibmcloud -d '*.example.com' -d example.com run ``` @@ -39,7 +39,7 @@ lego --email you@example.com --dns ibmcloud -d '*.example.com' -d example.com ru | Environment Variable Name | Description | |-----------------------|-------------| | `SOFTLAYER_API_KEY` | Classic Infrastructure API key | -| `SOFTLAYER_USERNAME` | Username (IBM Cloud is _) | +| `SOFTLAYER_USERNAME` | Username (IBM Cloud is {accountID}_{emailAddress}) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `SOFTLAYER_POLLING_INTERVAL` | Time between DNS propagation check | -| `SOFTLAYER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `SOFTLAYER_TIMEOUT` | API request timeout | -| `SOFTLAYER_TTL` | The TTL of the TXT record used for the DNS challenge | +| `SOFTLAYER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `SOFTLAYER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `SOFTLAYER_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `SOFTLAYER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_iij.md b/docs/content/dns/zz_gen_iij.md index b5e458db2..c7acfe3a0 100644 --- a/docs/content/dns/zz_gen_iij.md +++ b/docs/content/dns/zz_gen_iij.md @@ -29,7 +29,7 @@ Here is an example bash command using the Internet Initiative Japan provider: IIJ_API_ACCESS_KEY=xxxxxxxx \ IIJ_API_SECRET_KEY=yyyyyy \ IIJ_DO_SERVICE_CODE=zzzzzz \ -lego --email you@example.com --dns iij -d '*.example.com' -d example.com run +lego --dns iij -d '*.example.com' -d example.com run ``` @@ -51,9 +51,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `IIJ_POLLING_INTERVAL` | Time between DNS propagation check | -| `IIJ_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `IIJ_TTL` | The TTL of the TXT record used for the DNS challenge | +| `IIJ_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) | +| `IIJ_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 240) | +| `IIJ_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_iijdpf.md b/docs/content/dns/zz_gen_iijdpf.md index b9635ac06..12e126f49 100644 --- a/docs/content/dns/zz_gen_iijdpf.md +++ b/docs/content/dns/zz_gen_iijdpf.md @@ -28,7 +28,7 @@ Here is an example bash command using the IIJ DNS Platform Service provider: ```bash IIJ_DPF_API_TOKEN=xxxxxxxx \ IIJ_DPF_DPM_SERVICE_CODE=yyyyyy \ -lego --email you@example.com --dns iijdpf -d '*.example.com' -d example.com run +lego --dns iijdpf -d '*.example.com' -d example.com run ``` @@ -50,9 +50,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `IIJ_DPF_API_ENDPOINT` | API endpoint URL, defaults to https://api.dns-platform.jp/dpf/v1 | -| `IIJ_DPF_POLLING_INTERVAL` | Time between DNS propagation check, defaults to 5 second | -| `IIJ_DPF_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation, defaults to 660 second | -| `IIJ_DPF_TTL` | The TTL of the TXT record used for the DNS challenge, default to 300 | +| `IIJ_DPF_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | +| `IIJ_DPF_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 660) | +| `IIJ_DPF_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_infoblox.md b/docs/content/dns/zz_gen_infoblox.md index ba7af4855..74b80b2d1 100644 --- a/docs/content/dns/zz_gen_infoblox.md +++ b/docs/content/dns/zz_gen_infoblox.md @@ -29,7 +29,7 @@ Here is an example bash command using the Infoblox provider: INFOBLOX_USERNAME=api-user-529 \ INFOBLOX_PASSWORD=b9841238feb177a84330febba8a83208921177bffe733 \ INFOBLOX_HOST=infoblox.example.org -lego --email you@example.com --dns infoblox -d '*.example.com' -d example.com run +lego --dns infoblox -d '*.example.com' -d example.com run ``` @@ -51,14 +51,15 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `INFOBLOX_DNS_VIEW` | The view for the TXT records, default: External | -| `INFOBLOX_HTTP_TIMEOUT` | HTTP request timeout | -| `INFOBLOX_POLLING_INTERVAL` | Time between DNS propagation check | -| `INFOBLOX_PORT` | The port for the infoblox grid manager, default: 443 | -| `INFOBLOX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `INFOBLOX_SSL_VERIFY` | Whether or not to verify the TLS certificate, default: true | -| `INFOBLOX_TTL` | The TTL of the TXT record used for the DNS challenge | -| `INFOBLOX_WAPI_VERSION` | The version of WAPI being used, default: 2.11 | +| `INFOBLOX_CA_CERTIFICATE` | The path to the CA certificate (PEM encoded) | +| `INFOBLOX_DNS_VIEW` | The view for the TXT records (Default: External) | +| `INFOBLOX_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `INFOBLOX_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `INFOBLOX_PORT` | The port for the infoblox grid manager (Default: 443) | +| `INFOBLOX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `INFOBLOX_SSL_VERIFY` | Whether or not to verify the TLS certificate (Default: true) | +| `INFOBLOX_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | +| `INFOBLOX_WAPI_VERSION` | The version of WAPI being used (Default: 2.11) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_infomaniak.md b/docs/content/dns/zz_gen_infomaniak.md index 4b737d4af..7254241b1 100644 --- a/docs/content/dns/zz_gen_infomaniak.md +++ b/docs/content/dns/zz_gen_infomaniak.md @@ -27,7 +27,7 @@ Here is an example bash command using the Infomaniak provider: ```bash INFOMANIAK_ACCESS_TOKEN=1234567898765432 \ -lego --email you@example.com --dns infomaniak -d '*.example.com' -d example.com run +lego --dns infomaniak -d '*.example.com' -d example.com run ``` @@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `INFOMANIAK_ENDPOINT` | https://api.infomaniak.com | -| `INFOMANIAK_HTTP_TIMEOUT` | API request timeout | -| `INFOMANIAK_POLLING_INTERVAL` | Time between DNS propagation check | -| `INFOMANIAK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `INFOMANIAK_TTL` | The TTL of the TXT record used for the DNS challenge in seconds | +| `INFOMANIAK_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `INFOMANIAK_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `INFOMANIAK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `INFOMANIAK_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_internetbs.md b/docs/content/dns/zz_gen_internetbs.md index 3725bcb07..f0d9df3c1 100644 --- a/docs/content/dns/zz_gen_internetbs.md +++ b/docs/content/dns/zz_gen_internetbs.md @@ -28,7 +28,7 @@ Here is an example bash command using the Internet.bs provider: ```bash INTERNET_BS_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx \ INTERNET_BS_PASSWORD=yyyyyyyyyyyyyyyyyyyyyyyyyy \ -lego --email you@example.com --dns internetbs -d '*.example.com' -d example.com run +lego --dns internetbs -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `INTERNET_BS_HTTP_TIMEOUT` | API request timeout | -| `INTERNET_BS_POLLING_INTERVAL` | Time between DNS propagation check | -| `INTERNET_BS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `INTERNET_BS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `INTERNET_BS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `INTERNET_BS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `INTERNET_BS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `INTERNET_BS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_inwx.md b/docs/content/dns/zz_gen_inwx.md index b51d58c07..3e7d999e9 100644 --- a/docs/content/dns/zz_gen_inwx.md +++ b/docs/content/dns/zz_gen_inwx.md @@ -28,13 +28,13 @@ Here is an example bash command using the INWX provider: ```bash INWX_USERNAME=xxxxxxxxxx \ INWX_PASSWORD=yyyyyyyyyy \ -lego --email you@example.com --dns inwx -d '*.example.com' -d example.com run +lego --dns inwx -d '*.example.com' -d example.com run # 2FA INWX_USERNAME=xxxxxxxxxx \ INWX_PASSWORD=yyyyyyyyyy \ INWX_SHARED_SECRET=zzzzzzzzzz \ -lego --email you@example.com --dns inwx -d '*.example.com' -d example.com run +lego --dns inwx -d '*.example.com' -d example.com run ``` @@ -55,11 +55,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `INWX_POLLING_INTERVAL` | Time between DNS propagation check | -| `INWX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation (default 360s) | +| `INWX_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `INWX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 360) | | `INWX_SANDBOX` | Activate the sandbox (boolean) | | `INWX_SHARED_SECRET` | shared secret related to 2FA | -| `INWX_TTL` | The TTL of the TXT record used for the DNS challenge | +| `INWX_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_ionos.md b/docs/content/dns/zz_gen_ionos.md index 54d694da0..78bd3ffb1 100644 --- a/docs/content/dns/zz_gen_ionos.md +++ b/docs/content/dns/zz_gen_ionos.md @@ -27,7 +27,7 @@ Here is an example bash command using the Ionos provider: ```bash IONOS_API_KEY=xxxxxxxx \ -lego --email you@example.com --dns ionos -d '*.example.com' -d example.com run +lego --dns ionos -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `IONOS_HTTP_TIMEOUT` | API request timeout | -| `IONOS_POLLING_INTERVAL` | Time between DNS propagation check | -| `IONOS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `IONOS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `IONOS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `IONOS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `IONOS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 900) | +| `IONOS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_ionoscloud.md b/docs/content/dns/zz_gen_ionoscloud.md new file mode 100644 index 000000000..6007670a7 --- /dev/null +++ b/docs/content/dns/zz_gen_ionoscloud.md @@ -0,0 +1,67 @@ +--- +title: "Ionos Cloud" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: ionoscloud +dnsprovider: + since: "v4.30.0" + code: "ionoscloud" + url: "https://cloud.ionos.de/network/cloud-dns" +--- + + + + + + +Configuration for [Ionos Cloud](https://cloud.ionos.de/network/cloud-dns). + + + + +- Code: `ionoscloud` +- Since: v4.30.0 + + +Here is an example bash command using the Ionos Cloud provider: + +```bash +IONOSCLOUD_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns ionoscloud -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `IONOSCLOUD_API_TOKEN` | API token | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `IONOSCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `IONOSCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `IONOSCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `IONOSCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://api.ionos.com/docs/dns/v1/) + + + + diff --git a/docs/content/dns/zz_gen_ipv64.md b/docs/content/dns/zz_gen_ipv64.md index 6d7bcd24c..00a0292a6 100644 --- a/docs/content/dns/zz_gen_ipv64.md +++ b/docs/content/dns/zz_gen_ipv64.md @@ -27,7 +27,7 @@ Here is an example bash command using the IPv64 provider: ```bash IPV64_API_KEY=xxxxxx \ -lego --email you@example.com --dns ipv64 -d '*.example.com' -d example.com run +lego --dns ipv64 -d '*.example.com' -d example.com run ``` @@ -47,10 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `IPV64_HTTP_TIMEOUT` | API request timeout | -| `IPV64_POLLING_INTERVAL` | Time between DNS propagation check | -| `IPV64_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `IPV64_TTL` | The TTL of the TXT record used for the DNS challenge | +| `IPV64_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `IPV64_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `IPV64_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_ispconfig.md b/docs/content/dns/zz_gen_ispconfig.md new file mode 100644 index 000000000..e56f1f0b1 --- /dev/null +++ b/docs/content/dns/zz_gen_ispconfig.md @@ -0,0 +1,72 @@ +--- +title: "ISPConfig 3" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: ispconfig +dnsprovider: + since: "v4.31.0" + code: "ispconfig" + url: "https://www.ispconfig.org/" +--- + + + + + + +Configuration for [ISPConfig 3](https://www.ispconfig.org/). + + + + +- Code: `ispconfig` +- Since: v4.31.0 + + +Here is an example bash command using the ISPConfig 3 provider: + +```bash +ISPCONFIG_SERVER_URL="https://example.com:8080/remote/json.php" \ +ISPCONFIG_USERNAME="xxx" \ +ISPCONFIG_PASSWORD="yyy" \ +lego --dns ispconfig -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `ISPCONFIG_PASSWORD` | Password | +| `ISPCONFIG_SERVER_URL` | Server URL | +| `ISPCONFIG_USERNAME` | Username | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `ISPCONFIG_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `ISPCONFIG_INSECURE_SKIP_VERIFY` | Whether to verify the API certificate | +| `ISPCONFIG_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `ISPCONFIG_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `ISPCONFIG_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/index.html) + + + + diff --git a/docs/content/dns/zz_gen_ispconfigddns.md b/docs/content/dns/zz_gen_ispconfigddns.md new file mode 100644 index 000000000..3d1dd83c3 --- /dev/null +++ b/docs/content/dns/zz_gen_ispconfigddns.md @@ -0,0 +1,74 @@ +--- +title: "ISPConfig 3 - Dynamic DNS (DDNS) Module" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: ispconfigddns +dnsprovider: + since: "v4.31.0" + code: "ispconfigddns" + url: "https://www.ispconfig.org/" +--- + + + + + + +Configuration for [ISPConfig 3 - Dynamic DNS (DDNS) Module](https://www.ispconfig.org/). + + + + +- Code: `ispconfigddns` +- Since: v4.31.0 + + +Here is an example bash command using the ISPConfig 3 - Dynamic DNS (DDNS) Module provider: + +```bash +ISPCONFIG_DDNS_SERVER_URL="https://panel.example.com:8080" \ +ISPCONFIG_DDNS_TOKEN=xxxxxx \ +lego --dns ispconfigddns -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `ISPCONFIG_DDNS_SERVER_URL` | API server URL (ex: https://panel.example.com:8080) | +| `ISPCONFIG_DDNS_TOKEN` | DDNS API token | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `ISPCONFIG_DDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `ISPCONFIG_DDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `ISPCONFIG_DDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `ISPCONFIG_DDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + +ISPConfig DNS provider supports leveraging the [ISPConfig 3 Dynamic DNS (DDNS) Module](https://github.com/mhofer117/ispconfig-ddns-module). + +Requires the DDNS module described at https://www.ispconfig.org/ispconfig/download/ + +See https://www.howtoforge.com/community/threads/ispconfig-3-danymic-dns-ddns-module.87967/ for additional details. + + + +## More information + +- [API documentation](https://github.com/mhofer117/ispconfig-ddns-module/tree/master/lib/updater) + + + + diff --git a/docs/content/dns/zz_gen_iwantmyname.md b/docs/content/dns/zz_gen_iwantmyname.md index 8146a36ed..4638e1379 100644 --- a/docs/content/dns/zz_gen_iwantmyname.md +++ b/docs/content/dns/zz_gen_iwantmyname.md @@ -1,5 +1,5 @@ --- -title: "iwantmyname" +title: "iwantmyname (Deprecated)" date: 2019-03-03T16:39:46+01:00 draft: false slug: iwantmyname @@ -13,8 +13,10 @@ dnsprovider: +The iwantmyname API has shut down. + +https://github.com/go-acme/lego/issues/2563 -Configuration for [iwantmyname](https://iwantmyname.com). @@ -23,12 +25,12 @@ Configuration for [iwantmyname](https://iwantmyname.com). - Since: v4.7.0 -Here is an example bash command using the iwantmyname provider: +Here is an example bash command using the iwantmyname (Deprecated) provider: ```bash IWANTMYNAME_USERNAME=xxxxxxxx \ IWANTMYNAME_PASSWORD=xxxxxxxx \ -lego --email you@example.com --dns iwantmyname -d '*.example.com' -d example.com run +lego --dns iwantmyname -d '*.example.com' -d example.com run ``` @@ -49,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `IWANTMYNAME_HTTP_TIMEOUT` | API request timeout | -| `IWANTMYNAME_POLLING_INTERVAL` | Time between DNS propagation check | -| `IWANTMYNAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `IWANTMYNAME_TTL` | The TTL of the TXT record used for the DNS challenge | +| `IWANTMYNAME_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `IWANTMYNAME_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `IWANTMYNAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `IWANTMYNAME_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_jdcloud.md b/docs/content/dns/zz_gen_jdcloud.md new file mode 100644 index 000000000..a37cc3520 --- /dev/null +++ b/docs/content/dns/zz_gen_jdcloud.md @@ -0,0 +1,71 @@ +--- +title: "JD Cloud" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: jdcloud +dnsprovider: + since: "v4.31.0" + code: "jdcloud" + url: "https://www.jdcloud.com/" +--- + + + + + + +Configuration for [JD Cloud](https://www.jdcloud.com/). + + + + +- Code: `jdcloud` +- Since: v4.31.0 + + +Here is an example bash command using the JD Cloud provider: + +```bash +JDCLOUD_ACCESS_KEY_ID="xxx" \ +JDCLOUD_ACCESS_KEY_SECRET="yyy" \ +lego --dns jdcloud -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `JDCLOUD_ACCESS_KEY_ID` | Access key ID | +| `JDCLOUD_ACCESS_KEY_SECRET` | Access key secret | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `JDCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `JDCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `JDCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `JDCLOUD_REGION_ID` | Region ID (Default: cn-north-1) | +| `JDCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://docs.jdcloud.com/cn/jd-cloud-dns/api/overview) +- [Go client](https://github.com/jdcloud-api/jdcloud-sdk-go) + + + + diff --git a/docs/content/dns/zz_gen_joker.md b/docs/content/dns/zz_gen_joker.md index 2c0a6eafc..a5ecd47de 100644 --- a/docs/content/dns/zz_gen_joker.md +++ b/docs/content/dns/zz_gen_joker.md @@ -30,17 +30,17 @@ Here is an example bash command using the Joker provider: JOKER_API_MODE=SVC \ JOKER_USERNAME= \ JOKER_PASSWORD= \ -lego --email you@example.com --dns joker -d '*.example.com' -d example.com run +lego --dns joker -d '*.example.com' -d example.com run # DMAPI JOKER_API_MODE=DMAPI \ JOKER_USERNAME= \ JOKER_PASSWORD= \ -lego --email you@example.com --dns joker -d '*.example.com' -d example.com run +lego --dns joker -d '*.example.com' -d example.com run ## or JOKER_API_MODE=DMAPI \ JOKER_API_KEY= \ -lego --email you@example.com --dns joker -d '*.example.com' -d example.com run +lego --dns joker -d '*.example.com' -d example.com run ``` @@ -63,11 +63,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `JOKER_HTTP_TIMEOUT` | API request timeout | -| `JOKER_POLLING_INTERVAL` | Time between DNS propagation check | -| `JOKER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `JOKER_SEQUENCE_INTERVAL` | Time between sequential requests (only with 'SVC' mode) | -| `JOKER_TTL` | The TTL of the TXT record used for the DNS challenge | +| `JOKER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | +| `JOKER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `JOKER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `JOKER_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60), only with 'SVC' mode | +| `JOKER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_keyhelp.md b/docs/content/dns/zz_gen_keyhelp.md new file mode 100644 index 000000000..e39d3ce82 --- /dev/null +++ b/docs/content/dns/zz_gen_keyhelp.md @@ -0,0 +1,69 @@ +--- +title: "KeyHelp" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: keyhelp +dnsprovider: + since: "v4.26.0" + code: "keyhelp" + url: "https://www.keyweb.de/en/keyhelp/keyhelp/" +--- + + + + + + +Configuration for [KeyHelp](https://www.keyweb.de/en/keyhelp/keyhelp/). + + + + +- Code: `keyhelp` +- Since: v4.26.0 + + +Here is an example bash command using the KeyHelp provider: + +```bash +KEYHELP_BASE_URL="https://keyhelp.example.com" \ +KEYHELP_API_KEY="xxx" \ +lego --dns keyhelp -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `KEYHELP_API_KEY` | API key | +| `KEYHELP_BASE_URL` | Server URL | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `KEYHELP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `KEYHELP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `KEYHELP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `KEYHELP_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://app.swaggerhub.com/apis-docs/keyhelp/api/) + + + + diff --git a/docs/content/dns/zz_gen_leaseweb.md b/docs/content/dns/zz_gen_leaseweb.md new file mode 100644 index 000000000..13ded490a --- /dev/null +++ b/docs/content/dns/zz_gen_leaseweb.md @@ -0,0 +1,67 @@ +--- +title: "Leaseweb" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: leaseweb +dnsprovider: + since: "v4.32.0" + code: "leaseweb" + url: "https://www.leaseweb.com/en/" +--- + + + + + + +Configuration for [Leaseweb](https://www.leaseweb.com/en/). + + + + +- Code: `leaseweb` +- Since: v4.32.0 + + +Here is an example bash command using the Leaseweb provider: + +```bash +LEASEWEB_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns leaseweb -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `LEASEWEB_API_KEY` | API key | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `LEASEWEB_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `LEASEWEB_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `LEASEWEB_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `LEASEWEB_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://developer.leaseweb.com/docs/#tag/DNS) + + + + diff --git a/docs/content/dns/zz_gen_liara.md b/docs/content/dns/zz_gen_liara.md index 23bde4d79..658ce8077 100644 --- a/docs/content/dns/zz_gen_liara.md +++ b/docs/content/dns/zz_gen_liara.md @@ -27,7 +27,7 @@ Here is an example bash command using the Liara provider: ```bash LIARA_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ -lego --email you@example.com --dns liara -d '*.example.com' -d example.com run +lego --dns liara -d '*.example.com' -d example.com run ``` @@ -47,10 +47,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `LIARA_HTTP_TIMEOUT` | API request timeout | -| `LIARA_POLLING_INTERVAL` | Time between DNS propagation check | -| `LIARA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `LIARA_TTL` | The TTL of the TXT record used for the DNS challenge | +| `LIARA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `LIARA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `LIARA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `LIARA_TEAM_ID` | The team ID to access services in a team | +| `LIARA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). @@ -60,7 +61,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information -- [API documentation](https://dns-service.iran.liara.ir/swagger) +- [API documentation](https://openapi.liara.ir/?urls.primaryName=DNS) diff --git a/docs/content/dns/zz_gen_lightsail.md b/docs/content/dns/zz_gen_lightsail.md index f2bbaefb7..8e738611b 100644 --- a/docs/content/dns/zz_gen_lightsail.md +++ b/docs/content/dns/zz_gen_lightsail.md @@ -47,8 +47,8 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `AWS_SHARED_CREDENTIALS_FILE` | Managed by the AWS client. Shared credentials file. | -| `LIGHTSAIL_POLLING_INTERVAL` | Time between DNS propagation check | -| `LIGHTSAIL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `LIGHTSAIL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `LIGHTSAIL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_limacity.md b/docs/content/dns/zz_gen_limacity.md index fdaae55e6..29bc6e0a7 100644 --- a/docs/content/dns/zz_gen_limacity.md +++ b/docs/content/dns/zz_gen_limacity.md @@ -27,7 +27,7 @@ Here is an example bash command using the Lima-City provider: ```bash LIMACITY_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ -lego --email you@example.com --dns limacity -d '*.example.com' -d example.com run +lego --dns limacity -d '*.example.com' -d example.com run ``` @@ -47,11 +47,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `LIMACITY_HTTP_TIMEOUT` | API request timeout | -| `LIMACITY_POLLING_INTERVAL` | Time between DNS propagation check | -| `LIMACITY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `LIMACITY_SEQUENCE_INTERVAL` | Time between sequential requests | -| `LIMACITY_TTL` | The TTL of the TXT record used for the DNS challenge | +| `LIMACITY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `LIMACITY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 80) | +| `LIMACITY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 480) | +| `LIMACITY_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 90) | +| `LIMACITY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_linode.md b/docs/content/dns/zz_gen_linode.md index 8b97123b2..e41ba7cd9 100644 --- a/docs/content/dns/zz_gen_linode.md +++ b/docs/content/dns/zz_gen_linode.md @@ -27,7 +27,7 @@ Here is an example bash command using the Linode (v4) provider: ```bash LINODE_TOKEN=xxxxx \ -lego --email you@example.com --dns linode -d '*.example.com' -d example.com run +lego --dns linode -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `LINODE_HTTP_TIMEOUT` | API request timeout | -| `LINODE_POLLING_INTERVAL` | Time between DNS propagation check | -| `LINODE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `LINODE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `LINODE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `LINODE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 15) | +| `LINODE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `LINODE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_liquidweb.md b/docs/content/dns/zz_gen_liquidweb.md index 511ba9c92..bd2ce63b6 100644 --- a/docs/content/dns/zz_gen_liquidweb.md +++ b/docs/content/dns/zz_gen_liquidweb.md @@ -28,7 +28,7 @@ Here is an example bash command using the Liquid Web provider: ```bash LWAPI_USERNAME=someuser \ LWAPI_PASSWORD=somepass \ -lego --email you@example.com --dns liquidweb -d '*.example.com' -d example.com run +lego --dns liquidweb -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `LWAPI_HTTP_TIMEOUT` | Maximum waiting time for the DNS records to be created (not verified) | -| `LWAPI_POLLING_INTERVAL` | Time between DNS propagation check | -| `LWAPI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `LWAPI_TTL` | The TTL of the TXT record used for the DNS challenge | +| `LWAPI_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | +| `LWAPI_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `LWAPI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `LWAPI_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | | `LWAPI_URL` | Liquid Web API endpoint | | `LWAPI_ZONE` | DNS Zone | diff --git a/docs/content/dns/zz_gen_loopia.md b/docs/content/dns/zz_gen_loopia.md index 79827d325..bb3120c00 100644 --- a/docs/content/dns/zz_gen_loopia.md +++ b/docs/content/dns/zz_gen_loopia.md @@ -28,7 +28,7 @@ Here is an example bash command using the Loopia provider: ```bash LOOPIA_API_USER=xxxxxxxx \ LOOPIA_API_PASSWORD=yyyyyyyy \ -lego --email you@example.com --dns loopia -d '*.example.com' -d example.com run +lego --dns loopia -d '*.example.com' -d example.com run ``` @@ -50,10 +50,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `LOOPIA_API_URL` | API endpoint. Ex: https://api.loopia.se/RPCSERV or https://api.loopia.rs/RPCSERV | -| `LOOPIA_HTTP_TIMEOUT` | API request timeout | -| `LOOPIA_POLLING_INTERVAL` | Time between DNS propagation check | -| `LOOPIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `LOOPIA_TTL` | The TTL of the TXT record used for the DNS challenge | +| `LOOPIA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | +| `LOOPIA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2400) | +| `LOOPIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `LOOPIA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_luadns.md b/docs/content/dns/zz_gen_luadns.md index 2a6a02dd9..8bf718ba3 100644 --- a/docs/content/dns/zz_gen_luadns.md +++ b/docs/content/dns/zz_gen_luadns.md @@ -28,7 +28,7 @@ Here is an example bash command using the LuaDNS provider: ```bash LUADNS_API_USERNAME=youremail \ LUADNS_API_TOKEN=xxxxxxxx \ -lego --email you@example.com --dns luadns -d '*.example.com' -d example.com run +lego --dns luadns -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `LUADNS_HTTP_TIMEOUT` | API request timeout | -| `LUADNS_POLLING_INTERVAL` | Time between DNS propagation check | -| `LUADNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `LUADNS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `LUADNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `LUADNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `LUADNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `LUADNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_mailinabox.md b/docs/content/dns/zz_gen_mailinabox.md index f3269620f..62a6bdb57 100644 --- a/docs/content/dns/zz_gen_mailinabox.md +++ b/docs/content/dns/zz_gen_mailinabox.md @@ -29,7 +29,7 @@ Here is an example bash command using the Mail-in-a-Box provider: MAILINABOX_EMAIL=user@example.com \ MAILINABOX_PASSWORD=yyyy \ MAILINABOX_BASE_URL=https://box.example.com \ -lego --email you@example.com --dns mailinabox -d '*.example.com' -d example.com run +lego --dns mailinabox -d '*.example.com' -d example.com run ``` @@ -51,8 +51,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `MAILINABOX_POLLING_INTERVAL` | Time between DNS propagation check | -| `MAILINABOX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `MAILINABOX_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `MAILINABOX_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) | +| `MAILINABOX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_manageengine.md b/docs/content/dns/zz_gen_manageengine.md new file mode 100644 index 000000000..a39db8208 --- /dev/null +++ b/docs/content/dns/zz_gen_manageengine.md @@ -0,0 +1,68 @@ +--- +title: "ManageEngine CloudDNS" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: manageengine +dnsprovider: + since: "v4.21.0" + code: "manageengine" + url: "https://clouddns.manageengine.com" +--- + + + + + + +Configuration for [ManageEngine CloudDNS](https://clouddns.manageengine.com). + + + + +- Code: `manageengine` +- Since: v4.21.0 + + +Here is an example bash command using the ManageEngine CloudDNS provider: + +```bash +MANAGEENGINE_CLIENT_ID="xxx" \ +MANAGEENGINE_CLIENT_SECRET="yyy" \ +lego --dns manageengine -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `MANAGEENGINE_CLIENT_ID` | Client ID | +| `MANAGEENGINE_CLIENT_SECRET` | Client Secret | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `MANAGEENGINE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `MANAGEENGINE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `MANAGEENGINE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation) + + + + diff --git a/docs/content/dns/zz_gen_manual.md b/docs/content/dns/zz_gen_manual.md new file mode 100644 index 000000000..832ccaf58 --- /dev/null +++ b/docs/content/dns/zz_gen_manual.md @@ -0,0 +1,98 @@ +--- +title: "Manual" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: manual +dnsprovider: + since: "v0.3.0" + code: "manual" + url: "" +--- + + + + + +Solving the DNS-01 challenge using CLI prompt. + + + + +- Code: `manual` +- Since: v0.3.0 + + +Here is an example bash command using the Manual provider: + +```bash +lego --dns manual -d '*.example.com' -d example.com run +``` + + + + +## Example + +To start using the CLI prompt "provider", start lego with `--dns manual`: + +```console +$ lego --dns manual -d example.com run +``` + +What follows are a few log print-outs, interspersed with some prompts, asking for you to do perform some actions: + +```txt +No key found for account you@example.com. Generating a P256 key. +Saved key to ./.lego/accounts/acme-v02.api.letsencrypt.org/you@example.com/keys/you@example.com.key +Please review the TOS at https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf +Do you accept the TOS? Y/n +``` + +If you accept the linked Terms of Service, hit `Enter`. + +```txt +[INFO] acme: Registering account for you@example.com +!!!! HEADS UP !!!! + +Your account credentials have been saved in your +configuration directory at "./.lego/accounts". + +You should make a secure backup of this folder now. This +configuration directory will also contain private keys +generated by lego and certificates obtained from the ACME +server. Making regular backups of this folder is ideal. +[INFO] [example.com] acme: Obtaining bundled SAN certificate +[INFO] [example.com] AuthURL: https://acme-v02.api.letsencrypt.org/acme/authz-v3/2345678901 +[INFO] [example.com] acme: Could not find solver for: tls-alpn-01 +[INFO] [example.com] acme: Could not find solver for: http-01 +[INFO] [example.com] acme: use dns-01 solver +[INFO] [example.com] acme: Preparing to solve DNS-01 +lego: Please create the following TXT record in your example.com. zone: +_acme-challenge.example.com. 120 IN TXT "hX0dPkG6Gfs9hUvBAchQclkyyoEKbShbpvJ9mY5q2JQ" +lego: Press 'Enter' when you are done +``` + +Do as instructed, and create the TXT records, and hit `Enter`. + +```txt +[INFO] [example.com] acme: Trying to solve DNS-01 +[INFO] [example.com] acme: Checking DNS record propagation using [192.168.8.1:53] +[INFO] Wait for propagation [timeout: 1m0s, interval: 2s] +[INFO] [example.com] acme: Waiting for DNS record propagation. +[INFO] [example.com] The server validated our request +[INFO] [example.com] acme: Cleaning DNS-01 challenge +lego: You can now remove this TXT record from your example.com. zone: +_acme-challenge.example.com. 120 IN TXT "hX0dPkG6Gfs9hUvBAchQclkyyoEKbShbpvJ9mY5q2JQ" +[INFO] [example.com] acme: Validations succeeded; requesting certificates +[INFO] [example.com] Server responded with a certificate. +``` + +As mentioned, you can now remove the TXT record again. + + + + + + + + diff --git a/docs/content/dns/zz_gen_metaname.md b/docs/content/dns/zz_gen_metaname.md index ea794d4e5..156cf15eb 100644 --- a/docs/content/dns/zz_gen_metaname.md +++ b/docs/content/dns/zz_gen_metaname.md @@ -28,7 +28,7 @@ Here is an example bash command using the Metaname provider: ```bash METANAME_ACCOUNT_REFERENCE=xxxx \ METANAME_API_KEY=yyyyyyy \ -lego --email you@example.com --dns metaname -d '*.example.com' -d example.com run +lego --dns metaname -d '*.example.com' -d example.com run ``` @@ -49,9 +49,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `METANAME_POLLING_INTERVAL` | Time between DNS propagation check | -| `METANAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `METANAME_TTL` | The TTL of the TXT record used for the DNS challenge | +| `METANAME_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `METANAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `METANAME_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_metaregistrar.md b/docs/content/dns/zz_gen_metaregistrar.md new file mode 100644 index 000000000..22de046e2 --- /dev/null +++ b/docs/content/dns/zz_gen_metaregistrar.md @@ -0,0 +1,67 @@ +--- +title: "Metaregistrar" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: metaregistrar +dnsprovider: + since: "v4.23.0" + code: "metaregistrar" + url: "https://metaregistrar.com/" +--- + + + + + + +Configuration for [Metaregistrar](https://metaregistrar.com/). + + + + +- Code: `metaregistrar` +- Since: v4.23.0 + + +Here is an example bash command using the Metaregistrar provider: + +```bash +METAREGISTRAR_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns metaregistrar -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `METAREGISTRAR_API_TOKEN` | The API token | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `METAREGISTRAR_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `METAREGISTRAR_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `METAREGISTRAR_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `METAREGISTRAR_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://metaregistrar.dev/docu/metaapi/) + + + + diff --git a/docs/content/dns/zz_gen_mijnhost.md b/docs/content/dns/zz_gen_mijnhost.md index 65c1d953d..3d8f71aff 100644 --- a/docs/content/dns/zz_gen_mijnhost.md +++ b/docs/content/dns/zz_gen_mijnhost.md @@ -27,7 +27,7 @@ Here is an example bash command using the mijn.host provider: ```bash MIJNHOST_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ -lego --email you@example.com --dns mijnhost -d '*.example.com' -d example.com run +lego --dns mijnhost -d '*.example.com' -d example.com run ``` @@ -47,11 +47,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `MIJNHOST_HTTP_TIMEOUT` | API request timeout | -| `MIJNHOST_POLLING_INTERVAL` | Time between DNS propagation check | -| `MIJNHOST_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `MIJNHOST_SEQUENCE_INTERVAL` | Time between sequential requests | -| `MIJNHOST_TTL` | The TTL of the TXT record used for the DNS challenge | +| `MIJNHOST_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `MIJNHOST_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `MIJNHOST_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `MIJNHOST_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | +| `MIJNHOST_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_mittwald.md b/docs/content/dns/zz_gen_mittwald.md index c1edfe084..7714ef54f 100644 --- a/docs/content/dns/zz_gen_mittwald.md +++ b/docs/content/dns/zz_gen_mittwald.md @@ -27,7 +27,7 @@ Here is an example bash command using the Mittwald provider: ```bash MITTWALD_TOKEN=my-token \ -lego --email you@example.com --dns mittwald -d '*.example.com' -d example.com run +lego --dns mittwald -d '*.example.com' -d example.com run ``` @@ -47,11 +47,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `MITTWALD_HTTP_TIMEOUT` | API request timeout | -| `MITTWALD_POLLING_INTERVAL` | Time between DNS propagation check | -| `MITTWALD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `MITTWALD_SEQUENCE_INTERVAL` | Time between sequential requests | -| `MITTWALD_TTL` | The TTL of the TXT record used for the DNS challenge | +| `MITTWALD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `MITTWALD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `MITTWALD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `MITTWALD_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 120) | +| `MITTWALD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_myaddr.md b/docs/content/dns/zz_gen_myaddr.md new file mode 100644 index 000000000..4a52a058b --- /dev/null +++ b/docs/content/dns/zz_gen_myaddr.md @@ -0,0 +1,68 @@ +--- +title: "myaddr.{tools,dev,io}" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: myaddr +dnsprovider: + since: "v4.22.0" + code: "myaddr" + url: "https://myaddr.tools/" +--- + + + + + + +Configuration for [myaddr.{tools,dev,io}](https://myaddr.tools/). + + + + +- Code: `myaddr` +- Since: v4.22.0 + + +Here is an example bash command using the myaddr.{tools,dev,io} provider: + +```bash +MYADDR_PRIVATE_KEYS_MAPPING="example:123,test:456" \ +lego --dns myaddr -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `MYADDR_PRIVATE_KEYS_MAPPING` | Mapping between subdomains and private keys. The format is: `:,:,:` | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `MYADDR_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `MYADDR_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `MYADDR_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `MYADDR_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 2) | +| `MYADDR_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://myaddr.tools/) + + + + diff --git a/docs/content/dns/zz_gen_mydnsjp.md b/docs/content/dns/zz_gen_mydnsjp.md index 4fc899bf0..0a49404bb 100644 --- a/docs/content/dns/zz_gen_mydnsjp.md +++ b/docs/content/dns/zz_gen_mydnsjp.md @@ -28,7 +28,7 @@ Here is an example bash command using the MyDNS.jp provider: ```bash MYDNSJP_MASTER_ID=xxxxx \ MYDNSJP_PASSWORD=xxxxx \ -lego --email you@example.com --dns mydnsjp -d '*.example.com' -d example.com run +lego --dns mydnsjp -d '*.example.com' -d example.com run ``` @@ -49,10 +49,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `MYDNSJP_HTTP_TIMEOUT` | API request timeout | -| `MYDNSJP_POLLING_INTERVAL` | Time between DNS propagation check | -| `MYDNSJP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `MYDNSJP_TTL` | The TTL of the TXT record used for the DNS challenge | +| `MYDNSJP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `MYDNSJP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `MYDNSJP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_mythicbeasts.md b/docs/content/dns/zz_gen_mythicbeasts.md index 86e2ae5fd..70e38d249 100644 --- a/docs/content/dns/zz_gen_mythicbeasts.md +++ b/docs/content/dns/zz_gen_mythicbeasts.md @@ -28,7 +28,7 @@ Here is an example bash command using the MythicBeasts provider: ```bash MYTHICBEASTS_USERNAME=myuser \ MYTHICBEASTS_PASSWORD=mypass \ -lego --email you@example.com --dns mythicbeasts -d '*.example.com' -d example.com run +lego --dns mythicbeasts -d '*.example.com' -d example.com run ``` @@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). |--------------------------------|-------------| | `MYTHICBEASTS_API_ENDPOINT` | The endpoint for the API (must implement v2) | | `MYTHICBEASTS_AUTH_API_ENDPOINT` | The endpoint for Mythic Beasts' Authentication | -| `MYTHICBEASTS_HTTP_TIMEOUT` | API request timeout | -| `MYTHICBEASTS_POLLING_INTERVAL` | Time between DNS propagation check | -| `MYTHICBEASTS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `MYTHICBEASTS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `MYTHICBEASTS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `MYTHICBEASTS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `MYTHICBEASTS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `MYTHICBEASTS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_namecheap.md b/docs/content/dns/zz_gen_namecheap.md index 850a9ef8b..9d7143d84 100644 --- a/docs/content/dns/zz_gen_namecheap.md +++ b/docs/content/dns/zz_gen_namecheap.md @@ -33,7 +33,7 @@ Here is an example bash command using the Namecheap provider: ```bash NAMECHEAP_API_USER=user \ NAMECHEAP_API_KEY=key \ -lego --email you@example.com --dns namecheap -d '*.example.com' -d example.com run +lego --dns namecheap -d '*.example.com' -d example.com run ``` @@ -54,11 +54,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `NAMECHEAP_HTTP_TIMEOUT` | API request timeout | -| `NAMECHEAP_POLLING_INTERVAL` | Time between DNS propagation check | -| `NAMECHEAP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `NAMECHEAP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | +| `NAMECHEAP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 15) | +| `NAMECHEAP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 3600) | | `NAMECHEAP_SANDBOX` | Activate the sandbox (boolean) | -| `NAMECHEAP_TTL` | The TTL of the TXT record used for the DNS challenge | +| `NAMECHEAP_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_namedotcom.md b/docs/content/dns/zz_gen_namedotcom.md index df4c94559..2860ff0ae 100644 --- a/docs/content/dns/zz_gen_namedotcom.md +++ b/docs/content/dns/zz_gen_namedotcom.md @@ -28,7 +28,7 @@ Here is an example bash command using the Name.com provider: ```bash NAMECOM_USERNAME=foo.bar \ NAMECOM_API_TOKEN=a379a6f6eeafb9a55e378c118034e2751e682fab \ -lego --email you@example.com --dns namedotcom -d '*.example.com' -d example.com run +lego --dns namedotcom -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `NAMECOM_HTTP_TIMEOUT` | API request timeout | -| `NAMECOM_POLLING_INTERVAL` | Time between DNS propagation check | -| `NAMECOM_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `NAMECOM_TTL` | The TTL of the TXT record used for the DNS challenge | +| `NAMECOM_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `NAMECOM_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) | +| `NAMECOM_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 900) | +| `NAMECOM_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_namesilo.md b/docs/content/dns/zz_gen_namesilo.md index 1b69a3524..207a1603f 100644 --- a/docs/content/dns/zz_gen_namesilo.md +++ b/docs/content/dns/zz_gen_namesilo.md @@ -27,7 +27,7 @@ Here is an example bash command using the Namesilo provider: ```bash NAMESILO_API_KEY=b9841238feb177a84330febba8a83208921177bffe733 \ -lego --email you@example.com --dns namesilo -d '*.example.com' -d example.com run +lego --dns namesilo -d '*.example.com' -d example.com run ``` @@ -47,9 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `NAMESILO_POLLING_INTERVAL` | Time between DNS propagation check | -| `NAMESILO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation, it is better to set larger than 15m | -| `NAMESILO_TTL` | The TTL of the TXT record used for the DNS challenge, should be in [3600, 2592000] | +| `NAMESILO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `NAMESILO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60), it is better to set larger than 15 minutes | +| `NAMESILO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600), should be in [3600, 2592000] | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_namesurfer.md b/docs/content/dns/zz_gen_namesurfer.md new file mode 100644 index 000000000..9a2802d0e --- /dev/null +++ b/docs/content/dns/zz_gen_namesurfer.md @@ -0,0 +1,73 @@ +--- +title: "FusionLayer NameSurfer" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: namesurfer +dnsprovider: + since: "v4.32.0" + code: "namesurfer" + url: "https://www.fusionlayer.com/" +--- + + + + + + +Configuration for [FusionLayer NameSurfer](https://www.fusionlayer.com/). + + + + +- Code: `namesurfer` +- Since: v4.32.0 + + +Here is an example bash command using the FusionLayer NameSurfer provider: + +```bash +NAMESURFER_BASE_URL=https://foo.example.com:8443/API/NSService_10 \ +NAMESURFER_API_KEY=xxx \ +NAMESURFER_API_SECRET=yyy \ +lego --dns namesurfer -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `NAMESURFER_API_KEY` | API key name | +| `NAMESURFER_API_SECRET` | API secret | +| `NAMESURFER_BASE_URL` | The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `NAMESURFER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `NAMESURFER_INSECURE_SKIP_VERIFY` | Whether to verify the API certificate | +| `NAMESURFER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `NAMESURFER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `NAMESURFER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | +| `NAMESURFER_VIEW` | DNS view name (optional, default: empty string) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://web.archive.org/web/20260213170737/http://95.128.3.201:8053/API/NSService_10) + + + + diff --git a/docs/content/dns/zz_gen_nearlyfreespeech.md b/docs/content/dns/zz_gen_nearlyfreespeech.md index 1649fd34c..31402d2d2 100644 --- a/docs/content/dns/zz_gen_nearlyfreespeech.md +++ b/docs/content/dns/zz_gen_nearlyfreespeech.md @@ -28,7 +28,7 @@ Here is an example bash command using the NearlyFreeSpeech.NET provider: ```bash NEARLYFREESPEECH_API_KEY=xxxxxx \ NEARLYFREESPEECH_LOGIN=xxxx \ -lego --email you@example.com --dns nearlyfreespeech -d '*.example.com' -d example.com run +lego --dns nearlyfreespeech -d '*.example.com' -d example.com run ``` @@ -49,11 +49,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `NEARLYFREESPEECH_HTTP_TIMEOUT` | API request timeout | -| `NEARLYFREESPEECH_POLLING_INTERVAL` | Time between DNS propagation check | -| `NEARLYFREESPEECH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `NEARLYFREESPEECH_SEQUENCE_INTERVAL` | Time between sequential requests | -| `NEARLYFREESPEECH_TTL` | The TTL of the TXT record used for the DNS challenge | +| `NEARLYFREESPEECH_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `NEARLYFREESPEECH_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `NEARLYFREESPEECH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `NEARLYFREESPEECH_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | +| `NEARLYFREESPEECH_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_neodigit.md b/docs/content/dns/zz_gen_neodigit.md new file mode 100644 index 000000000..aefeef4bf --- /dev/null +++ b/docs/content/dns/zz_gen_neodigit.md @@ -0,0 +1,67 @@ +--- +title: "Neodigit" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: neodigit +dnsprovider: + since: "v4.30.0" + code: "neodigit" + url: "https://www.neodigit.net" +--- + + + + + + +Configuration for [Neodigit](https://www.neodigit.net). + + + + +- Code: `neodigit` +- Since: v4.30.0 + + +Here is an example bash command using the Neodigit provider: + +```bash +NEODIGIT_TOKEN=xxxxxx \ +lego --dns neodigit -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `NEODIGIT_TOKEN` | API token | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `NEODIGIT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `NEODIGIT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `NEODIGIT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | +| `NEODIGIT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://developers.neodigit.net/#dns) + + + + diff --git a/docs/content/dns/zz_gen_netcup.md b/docs/content/dns/zz_gen_netcup.md index e1973c814..29def3285 100644 --- a/docs/content/dns/zz_gen_netcup.md +++ b/docs/content/dns/zz_gen_netcup.md @@ -29,7 +29,7 @@ Here is an example bash command using the Netcup provider: NETCUP_CUSTOMER_NUMBER=xxxx \ NETCUP_API_KEY=yyyy \ NETCUP_API_PASSWORD=zzzz \ -lego --email you@example.com --dns netcup -d '*.example.com' -d example.com run +lego --dns netcup -d '*.example.com' -d example.com run ``` @@ -51,10 +51,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `NETCUP_HTTP_TIMEOUT` | API request timeout | -| `NETCUP_POLLING_INTERVAL` | Time between DNS propagation check | -| `NETCUP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `NETCUP_TTL` | The TTL of the TXT record used for the DNS challenge | +| `NETCUP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `NETCUP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) | +| `NETCUP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 900) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_netlify.md b/docs/content/dns/zz_gen_netlify.md index ad41146dc..76651d9ef 100644 --- a/docs/content/dns/zz_gen_netlify.md +++ b/docs/content/dns/zz_gen_netlify.md @@ -27,7 +27,7 @@ Here is an example bash command using the Netlify provider: ```bash NETLIFY_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns netlify -d '*.example.com' -d example.com run +lego --dns netlify -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `NETLIFY_HTTP_TIMEOUT` | API request timeout | -| `NETLIFY_POLLING_INTERVAL` | Time between DNS propagation check | -| `NETLIFY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `NETLIFY_TTL` | The TTL of the TXT record used for the DNS challenge | +| `NETLIFY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `NETLIFY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `NETLIFY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `NETLIFY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_nicmanager.md b/docs/content/dns/zz_gen_nicmanager.md index 1ae8806cc..a29d72120 100644 --- a/docs/content/dns/zz_gen_nicmanager.md +++ b/docs/content/dns/zz_gen_nicmanager.md @@ -34,7 +34,7 @@ NICMANAGER_API_PASSWORD = "password" \ # Optionally, if your account has TOTP enabled, set the secret here NICMANAGER_API_OTP = "long-secret" \ -lego --email you@example.com --dns nicmanager -d '*.example.com' -d example.com run +lego --dns nicmanager -d '*.example.com' -d example.com run ## Login using account name + username @@ -45,7 +45,7 @@ NICMANAGER_API_PASSWORD = "password" \ # Optionally, if your account has TOTP enabled, set the secret here NICMANAGER_API_OTP = "long-secret" \ -lego --email you@example.com --dns nicmanager -d '*.example.com' -d example.com run +lego --dns nicmanager -d '*.example.com' -d example.com run ``` @@ -68,12 +68,12 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `NICMANAGER_API_MODE` | mode: 'anycast' or 'zone' (default: 'anycast') | +| `NICMANAGER_API_MODE` | mode: 'anycast' or 'zones' (for FreeDNS) (default: 'anycast') | | `NICMANAGER_API_OTP` | TOTP Secret (optional) | -| `NICMANAGER_HTTP_TIMEOUT` | API request timeout | -| `NICMANAGER_POLLING_INTERVAL` | Time between DNS propagation check | -| `NICMANAGER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `NICMANAGER_TTL` | The TTL of the TXT record used for the DNS challenge | +| `NICMANAGER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `NICMANAGER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `NICMANAGER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | +| `NICMANAGER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 900) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). @@ -81,7 +81,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Description You can log in using your account name + username or using your email address. -Optionally if TOTP is configured for your account, set `NICMANAGER_API_OTP`. +Optionally, if TOTP is configured for your account, set `NICMANAGER_API_OTP`. diff --git a/docs/content/dns/zz_gen_nicru.md b/docs/content/dns/zz_gen_nicru.md new file mode 100644 index 000000000..3ac8d99cf --- /dev/null +++ b/docs/content/dns/zz_gen_nicru.md @@ -0,0 +1,83 @@ +--- +title: "RU CENTER" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: nicru +dnsprovider: + since: "v4.24.0" + code: "nicru" + url: "https://nic.ru/" +--- + + + + + + +Configuration for [RU CENTER](https://nic.ru/). + + + + +- Code: `nicru` +- Since: v4.24.0 + + +Here is an example bash command using the RU CENTER provider: + +```bash +NICRU_USER="" \ +NICRU_PASSWORD="" \ +NICRU_SERVICE_ID="" \ +NICRU_SECRET="" \ +lego --dns nicru -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `NICRU_PASSWORD` | Password for an account in RU CENTER | +| `NICRU_SECRET` | Secret for application in DNS-hosting RU CENTER | +| `NICRU_SERVICE_ID` | Service ID for application in DNS-hosting RU CENTER | +| `NICRU_SERVICE_NAME` | Service Name for DNS-hosting RU CENTER | +| `NICRU_USER` | Agreement for an account in RU CENTER | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `NICRU_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 60) | +| `NICRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) | +| `NICRU_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 30) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + +## Credential information + +You can find information about service ID and secret https://www.nic.ru/manager/oauth.cgi?step=oauth.app_list + +| ENV Variable | Parameter from page | Example | +|---------------------|--------------------------------|-------------------| +| NICRU_USER | Username (Number of agreement) | NNNNNNN/NIC-D | +| NICRU_PASSWORD | Password account | | +| NICRU_SERVICE_ID | Application ID | hex-based, len 32 | +| NICRU_SECRET | Identity endpoint | string len 91 | + + + +## More information + +- [API documentation](https://www.nic.ru/help/api-dns-hostinga_3643.html) + + + + diff --git a/docs/content/dns/zz_gen_nifcloud.md b/docs/content/dns/zz_gen_nifcloud.md index bd5d25321..66f38223b 100644 --- a/docs/content/dns/zz_gen_nifcloud.md +++ b/docs/content/dns/zz_gen_nifcloud.md @@ -28,7 +28,7 @@ Here is an example bash command using the NIFCloud provider: ```bash NIFCLOUD_ACCESS_KEY_ID=xxxx \ NIFCLOUD_SECRET_ACCESS_KEY=yyyy \ -lego --email you@example.com --dns nifcloud -d '*.example.com' -d example.com run +lego --dns nifcloud -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `NIFCLOUD_HTTP_TIMEOUT` | API request timeout | -| `NIFCLOUD_POLLING_INTERVAL` | Time between DNS propagation check | -| `NIFCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `NIFCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge | +| `NIFCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `NIFCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `NIFCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `NIFCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_njalla.md b/docs/content/dns/zz_gen_njalla.md index f846cf1e8..9a312df8b 100644 --- a/docs/content/dns/zz_gen_njalla.md +++ b/docs/content/dns/zz_gen_njalla.md @@ -27,7 +27,7 @@ Here is an example bash command using the Njalla provider: ```bash NJALLA_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns njalla -d '*.example.com' -d example.com run +lego --dns njalla -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `NJALLA_HTTP_TIMEOUT` | API request timeout | -| `NJALLA_POLLING_INTERVAL` | Time between DNS propagation check | -| `NJALLA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `NJALLA_TTL` | The TTL of the TXT record used for the DNS challenge | +| `NJALLA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `NJALLA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `NJALLA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `NJALLA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_nodion.md b/docs/content/dns/zz_gen_nodion.md index fc1f820f8..8d61eb834 100644 --- a/docs/content/dns/zz_gen_nodion.md +++ b/docs/content/dns/zz_gen_nodion.md @@ -27,7 +27,7 @@ Here is an example bash command using the Nodion provider: ```bash NODION_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ -lego --email you@example.com --dns nodion -d '*.example.com' -d example.com run +lego --dns nodion -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `NODION_HTTP_TIMEOUT` | API request timeout | -| `NODION_POLLING_INTERVAL` | Time between DNS propagation check | -| `NODION_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `NODION_TTL` | The TTL of the TXT record used for the DNS challenge | +| `NODION_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `NODION_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `NODION_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `NODION_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_ns1.md b/docs/content/dns/zz_gen_ns1.md index 9e4c906ad..b2262169d 100644 --- a/docs/content/dns/zz_gen_ns1.md +++ b/docs/content/dns/zz_gen_ns1.md @@ -27,7 +27,7 @@ Here is an example bash command using the NS1 provider: ```bash NS1_API_KEY=xxxx \ -lego --email you@example.com --dns ns1 -d '*.example.com' -d example.com run +lego --dns ns1 -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `NS1_HTTP_TIMEOUT` | API request timeout | -| `NS1_POLLING_INTERVAL` | Time between DNS propagation check | -| `NS1_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `NS1_TTL` | The TTL of the TXT record used for the DNS challenge | +| `NS1_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `NS1_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `NS1_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `NS1_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_octenium.md b/docs/content/dns/zz_gen_octenium.md new file mode 100644 index 000000000..f25da4f44 --- /dev/null +++ b/docs/content/dns/zz_gen_octenium.md @@ -0,0 +1,67 @@ +--- +title: "Octenium" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: octenium +dnsprovider: + since: "v4.27.0" + code: "octenium" + url: "https://octenium.com/" +--- + + + + + + +Configuration for [Octenium](https://octenium.com/). + + + + +- Code: `octenium` +- Since: v4.27.0 + + +Here is an example bash command using the Octenium provider: + +```bash +OCTENIUM_API_KEY="xxx" \ +lego --dns octenium -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `OCTENIUM_API_KEY` | API key | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `OCTENIUM_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `OCTENIUM_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `OCTENIUM_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `OCTENIUM_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://octenium.com/api#tag/Domains-DNS) + + + + diff --git a/docs/content/dns/zz_gen_oraclecloud.md b/docs/content/dns/zz_gen_oraclecloud.md index 1b6647ce5..b7192f380 100644 --- a/docs/content/dns/zz_gen_oraclecloud.md +++ b/docs/content/dns/zz_gen_oraclecloud.md @@ -26,14 +26,21 @@ Configuration for [Oracle Cloud](https://cloud.oracle.com/home). Here is an example bash command using the Oracle Cloud provider: ```bash -OCI_PRIVKEY_FILE="~/.oci/oci_api_key.pem" \ -OCI_PRIVKEY_PASS="secret" \ +# Using API Key authentication: +OCI_PRIVATE_KEY_PATH="~/.oci/oci_api_key.pem" \ +OCI_PRIVATE_KEY_PASSWORD="secret" \ OCI_TENANCY_OCID="ocid1.tenancy.oc1..secret" \ OCI_USER_OCID="ocid1.user.oc1..secret" \ -OCI_PUBKEY_FINGERPRINT="00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00" \ +OCI_FINGERPRINT="00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00" \ OCI_REGION="us-phoenix-1" \ OCI_COMPARTMENT_OCID="ocid1.tenancy.oc1..secret" \ -lego --email you@example.com --dns oraclecloud -d '*.example.com' -d example.com run +lego --dns oraclecloud -d '*.example.com' -d example.com run + +# Using Instance Principal authentication (when running on OCI compute instances): +# https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/callingservicesfrominstances.htm +OCI_AUTH_TYPE="instance_principal" \ +OCI_COMPARTMENT_OCID="ocid1.tenancy.oc1..secret" \ +lego --dns oraclecloud -d '*.example.com' -d example.com run ``` @@ -44,12 +51,12 @@ lego --email you@example.com --dns oraclecloud -d '*.example.com' -d example.com | Environment Variable Name | Description | |-----------------------|-------------| | `OCI_COMPARTMENT_OCID` | Compartment OCID | -| `OCI_PRIVKEY_FILE` | Private key file | -| `OCI_PRIVKEY_PASS` | Private key password | -| `OCI_PUBKEY_FINGERPRINT` | Public key fingerprint | -| `OCI_REGION` | Region | -| `OCI_TENANCY_OCID` | Tenancy OCID | -| `OCI_USER_OCID` | User OCID | +| `OCI_FINGERPRINT` | Public key fingerprint (ignored if `OCI_AUTH_TYPE=instance_principal`) | +| `OCI_PRIVATE_KEY_PASSWORD` | Private key password (ignored if `OCI_AUTH_TYPE=instance_principal`) | +| `OCI_PRIVATE_KEY_PATH` | Private key file (ignored if `OCI_AUTH_TYPE=instance_principal`) | +| `OCI_REGION` | Region (it can be empty if `OCI_AUTH_TYPE=instance_principal`). | +| `OCI_TENANCY_OCID` | Tenancy OCID (ignored if `OCI_AUTH_TYPE=instance_principal`) | +| `OCI_USER_OCID` | User OCID (ignored if `OCI_AUTH_TYPE=instance_principal`) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). @@ -59,9 +66,16 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `OCI_POLLING_INTERVAL` | Time between DNS propagation check | -| `OCI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `OCI_TTL` | The TTL of the TXT record used for the DNS challenge | +| `OCI_AUTH_TYPE` | Authorization type. Possible values: 'instance_principal', '' (Default: '') | +| `OCI_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | +| `OCI_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `OCI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `OCI_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | +| `TF_VAR_fingerprint` | Alias on `OCI_FINGERPRINT` | +| `TF_VAR_private_key_path` | Alias on `OCI_PRIVATE_KEY_PATH` | +| `TF_VAR_region` | Alias on `OCI_REGION` | +| `TF_VAR_tenancy_ocid` | Alias on `OCI_TENANCY_OCID` | +| `TF_VAR_user_ocid` | Alias on `OCI_USER_OCID` | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_otc.md b/docs/content/dns/zz_gen_otc.md index 0de59fd64..9da69c694 100644 --- a/docs/content/dns/zz_gen_otc.md +++ b/docs/content/dns/zz_gen_otc.md @@ -23,9 +23,15 @@ Configuration for [Open Telekom Cloud](https://cloud.telekom.de/en). - Since: v0.4.1 -{{% notice note %}} -_Please contribute by adding a CLI example._ -{{% /notice %}} +Here is an example bash command using the Open Telekom Cloud provider: + +```bash +OTC_DOMAIN_NAME=domain_name \ +OTC_USER_NAME=user_name \ +OTC_PASSWORD=password \ +OTC_PROJECT_NAME=project_name \ +lego --dns otc -d '*.example.com' -d example.com run +``` @@ -35,7 +41,6 @@ _Please contribute by adding a CLI example._ | Environment Variable Name | Description | |-----------------------|-------------| | `OTC_DOMAIN_NAME` | Domain name | -| `OTC_IDENTITY_ENDPOINT` | Identity endpoint URL | | `OTC_PASSWORD` | Password | | `OTC_PROJECT_NAME` | Project name | | `OTC_USER_NAME` | User name | @@ -48,11 +53,13 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `OTC_HTTP_TIMEOUT` | API request timeout | -| `OTC_POLLING_INTERVAL` | Time between DNS propagation check | -| `OTC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `OTC_SEQUENCE_INTERVAL` | Time between sequential requests | -| `OTC_TTL` | The TTL of the TXT record used for the DNS challenge | +| `OTC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `OTC_IDENTITY_ENDPOINT` | Identity endpoint URL (default: https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens) | +| `OTC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `OTC_PRIVATE_ZONE` | Set to true to use private zones only (default: use public zones only) | +| `OTC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `OTC_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | +| `OTC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_ovh.md b/docs/content/dns/zz_gen_ovh.md index fad507cbd..aaafded85 100644 --- a/docs/content/dns/zz_gen_ovh.md +++ b/docs/content/dns/zz_gen_ovh.md @@ -32,20 +32,20 @@ OVH_APPLICATION_KEY=1234567898765432 \ OVH_APPLICATION_SECRET=b9841238feb177a84330febba8a832089 \ OVH_CONSUMER_KEY=256vfsd347245sdfg \ OVH_ENDPOINT=ovh-eu \ -lego --email you@example.com --dns ovh -d '*.example.com' -d example.com run +lego --dns ovh -d '*.example.com' -d example.com run # Or Access Token: OVH_ACCESS_TOKEN=xxx \ OVH_ENDPOINT=ovh-eu \ -lego --email you@example.com --dns ovh -d '*.example.com' -d example.com run +lego --dns ovh -d '*.example.com' -d example.com run # Or OAuth2: OVH_CLIENT_ID=yyy \ OVH_CLIENT_SECRET=xxx \ OVH_ENDPOINT=ovh-eu \ -lego --email you@example.com --dns ovh -d '*.example.com' -d example.com run +lego --dns ovh -d '*.example.com' -d example.com run ``` @@ -71,10 +71,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `OVH_HTTP_TIMEOUT` | API request timeout | -| `OVH_POLLING_INTERVAL` | Time between DNS propagation check | -| `OVH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `OVH_TTL` | The TTL of the TXT record used for the DNS challenge | +| `OVH_HTTP_TIMEOUT` | API request timeout in seconds (Default: 180) | +| `OVH_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `OVH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `OVH_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_pdns.md b/docs/content/dns/zz_gen_pdns.md index 31870fbc0..7c2a8c663 100644 --- a/docs/content/dns/zz_gen_pdns.md +++ b/docs/content/dns/zz_gen_pdns.md @@ -28,7 +28,7 @@ Here is an example bash command using the PowerDNS provider: ```bash PDNS_API_URL=http://pdns-server:80/ \ PDNS_API_KEY=xxxx \ -lego --email you@example.com --dns pdns -d '*.example.com' -d example.com run +lego --dns pdns -d '*.example.com' -d example.com run ``` @@ -50,11 +50,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `PDNS_API_VERSION` | Skip API version autodetection and use the provided version number. | -| `PDNS_HTTP_TIMEOUT` | API request timeout | -| `PDNS_POLLING_INTERVAL` | Time between DNS propagation check | -| `PDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `PDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `PDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `PDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `PDNS_SERVER_NAME` | Name of the server in the URL, 'localhost' by default | -| `PDNS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `PDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_plesk.md b/docs/content/dns/zz_gen_plesk.md index 5c9d060cf..73ec9a55d 100644 --- a/docs/content/dns/zz_gen_plesk.md +++ b/docs/content/dns/zz_gen_plesk.md @@ -29,7 +29,7 @@ Here is an example bash command using the plesk.com provider: PLESK_SERVER_BASE_URL="https://plesk.myserver.com:8443" \ PLESK_USERNAME=xxxxxx \ PLESK_PASSWORD=yyyyyy \ -lego --email you@example.com --dns plesk -d '*.example.com' -d example.com run +lego --dns plesk -d '*.example.com' -d example.com run ``` @@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `PLESK_HTTP_TIMEOUT` | API request timeout | -| `PLESK_POLLING_INTERVAL` | Time between DNS propagation check | -| `PLESK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `PLESK_TTL` | The TTL of the TXT record used for the DNS challenge | +| `PLESK_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `PLESK_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `PLESK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `PLESK_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_porkbun.md b/docs/content/dns/zz_gen_porkbun.md index 5e96e239e..f54e6f688 100644 --- a/docs/content/dns/zz_gen_porkbun.md +++ b/docs/content/dns/zz_gen_porkbun.md @@ -28,7 +28,7 @@ Here is an example bash command using the Porkbun provider: ```bash PORKBUN_SECRET_API_KEY=xxxxxx \ PORKBUN_API_KEY=yyyyyy \ -lego --email you@example.com --dns porkbun -d '*.example.com' -d example.com run +lego --dns porkbun -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `PORKBUN_HTTP_TIMEOUT` | API request timeout | -| `PORKBUN_POLLING_INTERVAL` | Time between DNS propagation check | -| `PORKBUN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `PORKBUN_TTL` | The TTL of the TXT record used for the DNS challenge | +| `PORKBUN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `PORKBUN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `PORKBUN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) | +| `PORKBUN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_rackspace.md b/docs/content/dns/zz_gen_rackspace.md index bbdd8cbfb..b9a2ab710 100644 --- a/docs/content/dns/zz_gen_rackspace.md +++ b/docs/content/dns/zz_gen_rackspace.md @@ -28,7 +28,7 @@ Here is an example bash command using the Rackspace provider: ```bash RACKSPACE_USER=xxxx \ RACKSPACE_API_KEY=yyyy \ -lego --email you@example.com --dns rackspace -d '*.example.com' -d example.com run +lego --dns rackspace -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `RACKSPACE_HTTP_TIMEOUT` | API request timeout | -| `RACKSPACE_POLLING_INTERVAL` | Time between DNS propagation check | -| `RACKSPACE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `RACKSPACE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `RACKSPACE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `RACKSPACE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 3) | +| `RACKSPACE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `RACKSPACE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_rainyun.md b/docs/content/dns/zz_gen_rainyun.md new file mode 100644 index 000000000..680eb845a --- /dev/null +++ b/docs/content/dns/zz_gen_rainyun.md @@ -0,0 +1,67 @@ +--- +title: "Rain Yun/雨云" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: rainyun +dnsprovider: + since: "v4.21.0" + code: "rainyun" + url: "https://www.rainyun.com" +--- + + + + + + +Configuration for [Rain Yun/雨云](https://www.rainyun.com). + + + + +- Code: `rainyun` +- Since: v4.21.0 + + +Here is an example bash command using the Rain Yun/雨云 provider: + +```bash +RAINYUN_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns rainyun -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `RAINYUN_API_KEY` | API key | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `RAINYUN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `RAINYUN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `RAINYUN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `RAINYUN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://www.apifox.cn/apidoc/shared-a4595cc8-44c5-4678-a2a3-eed7738dab03/api-151416609) + + + + diff --git a/docs/content/dns/zz_gen_rcodezero.md b/docs/content/dns/zz_gen_rcodezero.md index 8677de764..a544df420 100644 --- a/docs/content/dns/zz_gen_rcodezero.md +++ b/docs/content/dns/zz_gen_rcodezero.md @@ -27,7 +27,7 @@ Here is an example bash command using the RcodeZero provider: ```bash RCODEZERO_API_TOKEN= \ -lego --email you@example.com --dns rcodezero -d '*.example.com' -d example.com run +lego --dns rcodezero -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `RCODEZERO_HTTP_TIMEOUT` | API request timeout | -| `RCODEZERO_POLLING_INTERVAL` | Time between DNS propagation check | -| `RCODEZERO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `RCODEZERO_TTL` | The TTL of the TXT record used for the DNS challenge | +| `RCODEZERO_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `RCODEZERO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `RCODEZERO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 240) | +| `RCODEZERO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_regfish.md b/docs/content/dns/zz_gen_regfish.md index f5310db53..357ce0764 100644 --- a/docs/content/dns/zz_gen_regfish.md +++ b/docs/content/dns/zz_gen_regfish.md @@ -27,7 +27,7 @@ Here is an example bash command using the Regfish provider: ```bash REGFISH_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ -lego --email you@example.com --dns regfish -d '*.example.com' -d example.com run +lego --dns regfish -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `REGFISH_HTTP_TIMEOUT` | API request timeout | -| `REGFISH_POLLING_INTERVAL` | Time between DNS propagation check | -| `REGFISH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `REGFISH_TTL` | The TTL of the TXT record used for the DNS challenge | +| `REGFISH_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `REGFISH_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `REGFISH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `REGFISH_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_regru.md b/docs/content/dns/zz_gen_regru.md index 8c6bea662..eaf163a13 100644 --- a/docs/content/dns/zz_gen_regru.md +++ b/docs/content/dns/zz_gen_regru.md @@ -28,7 +28,7 @@ Here is an example bash command using the reg.ru provider: ```bash REGRU_USERNAME=xxxxxx \ REGRU_PASSWORD=yyyyyy \ -lego --email you@example.com --dns regru -d '*.example.com' -d example.com run +lego --dns regru -d '*.example.com' -d example.com run ``` @@ -49,12 +49,12 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `REGRU_HTTP_TIMEOUT` | API request timeout | -| `REGRU_POLLING_INTERVAL` | Time between DNS propagation check | -| `REGRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `REGRU_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `REGRU_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `REGRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `REGRU_TLS_CERT` | authentication certificate | | `REGRU_TLS_KEY` | authentication private key | -| `REGRU_TTL` | The TTL of the TXT record used for the DNS challenge | +| `REGRU_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_rfc2136.md b/docs/content/dns/zz_gen_rfc2136.md index ad52005d4..1b1d43dd5 100644 --- a/docs/content/dns/zz_gen_rfc2136.md +++ b/docs/content/dns/zz_gen_rfc2136.md @@ -30,7 +30,7 @@ RFC2136_NAMESERVER=127.0.0.1 \ RFC2136_TSIG_KEY=example.com \ RFC2136_TSIG_ALGORITHM=hmac-sha256. \ RFC2136_TSIG_SECRET=YWJjZGVmZGdoaWprbG1ub3BxcnN0dXZ3eHl6MTIzNDU= \ -lego --email you@example.com --dns rfc2136 -d '*.example.com' -d example.com run +lego --dns rfc2136 -d '*.example.com' -d example.com run ## --- @@ -38,7 +38,7 @@ keyname=example.com; keyfile=example.com.key; tsig-keygen $keyname > $keyfile RFC2136_NAMESERVER=127.0.0.1 \ RFC2136_TSIG_FILE="$keyfile" \ -lego --email you@example.com --dns rfc2136 -d '*.example.com' -d example.com run +lego --dns rfc2136 -d '*.example.com' -d example.com run ``` @@ -61,12 +61,12 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `RFC2136_DNS_TIMEOUT` | API request timeout | -| `RFC2136_POLLING_INTERVAL` | Time between DNS propagation check | -| `RFC2136_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `RFC2136_SEQUENCE_INTERVAL` | Time between sequential requests | +| `RFC2136_DNS_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `RFC2136_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `RFC2136_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `RFC2136_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | | `RFC2136_TSIG_FILE` | Path to a key file generated by tsig-keygen | -| `RFC2136_TTL` | The TTL of the TXT record used for the DNS challenge | +| `RFC2136_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_rimuhosting.md b/docs/content/dns/zz_gen_rimuhosting.md index 46687484c..acb829e93 100644 --- a/docs/content/dns/zz_gen_rimuhosting.md +++ b/docs/content/dns/zz_gen_rimuhosting.md @@ -27,7 +27,7 @@ Here is an example bash command using the RimuHosting provider: ```bash RIMUHOSTING_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns rimuhosting -d '*.example.com' -d example.com run +lego --dns rimuhosting -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `RIMUHOSTING_HTTP_TIMEOUT` | API request timeout | -| `RIMUHOSTING_POLLING_INTERVAL` | Time between DNS propagation check | -| `RIMUHOSTING_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `RIMUHOSTING_TTL` | The TTL of the TXT record used for the DNS challenge | +| `RIMUHOSTING_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `RIMUHOSTING_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `RIMUHOSTING_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `RIMUHOSTING_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_route53.md b/docs/content/dns/zz_gen_route53.md index cd18a5c1d..59e489d6a 100644 --- a/docs/content/dns/zz_gen_route53.md +++ b/docs/content/dns/zz_gen_route53.md @@ -30,7 +30,7 @@ AWS_ACCESS_KEY_ID=your_key_id \ AWS_SECRET_ACCESS_KEY=your_secret_access_key \ AWS_REGION=aws-region \ AWS_HOSTED_ZONE_ID=your_hosted_zone_id \ -lego --email you@example.com --dns route53 -d '*.example.com' -d example.com run +lego --dns route53 -d '*.example.com' -d example.com run ``` @@ -59,10 +59,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `AWS_MAX_RETRIES` | The number of maximum returns the service will use to make an individual API request | -| `AWS_POLLING_INTERVAL` | Time between DNS propagation check | -| `AWS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `AWS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) | +| `AWS_PRIVATE_ZONE` | Set to true to use private zones only (default: use public zones only) | +| `AWS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `AWS_SHARED_CREDENTIALS_FILE` | Managed by the AWS client. Shared credentials file. | -| `AWS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `AWS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 10) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_safedns.md b/docs/content/dns/zz_gen_safedns.md index c6d4cd745..4c20fca6a 100644 --- a/docs/content/dns/zz_gen_safedns.md +++ b/docs/content/dns/zz_gen_safedns.md @@ -1,12 +1,12 @@ --- -title: "UKFast SafeDNS" +title: "ANS SafeDNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: safedns dnsprovider: since: "v4.6.0" code: "safedns" - url: "https://www.ukfast.co.uk/dns-hosting.html" + url: "https://www.ans.co.uk/" --- @@ -14,7 +14,7 @@ dnsprovider: -Configuration for [UKFast SafeDNS](https://www.ukfast.co.uk/dns-hosting.html). +Configuration for [ANS SafeDNS](https://www.ans.co.uk/). @@ -23,11 +23,11 @@ Configuration for [UKFast SafeDNS](https://www.ukfast.co.uk/dns-hosting.html). - Since: v4.6.0 -Here is an example bash command using the UKFast SafeDNS provider: +Here is an example bash command using the ANS SafeDNS provider: ```bash SAFEDNS_AUTH_TOKEN=xxxxxx \ -lego --email you@example.com --dns safedns -d '*.example.com' -d example.com run +lego --dns safedns -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `SAFEDNS_HTTP_TIMEOUT` | API request timeout | -| `SAFEDNS_POLLING_INTERVAL` | Time between DNS propagation check | -| `SAFEDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `SAFEDNS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `SAFEDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `SAFEDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `SAFEDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `SAFEDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_sakuracloud.md b/docs/content/dns/zz_gen_sakuracloud.md index e0af53acf..b43f83ef4 100644 --- a/docs/content/dns/zz_gen_sakuracloud.md +++ b/docs/content/dns/zz_gen_sakuracloud.md @@ -28,7 +28,7 @@ Here is an example bash command using the Sakura Cloud provider: ```bash SAKURACLOUD_ACCESS_TOKEN=xxxxx \ SAKURACLOUD_ACCESS_TOKEN_SECRET=yyyyy \ -lego --email you@example.com --dns sakuracloud -d '*.example.com' -d example.com run +lego --dns sakuracloud -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `SAKURACLOUD_HTTP_TIMEOUT` | API request timeout | -| `SAKURACLOUD_POLLING_INTERVAL` | Time between DNS propagation check | -| `SAKURACLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `SAKURACLOUD_TTL` | The TTL of the TXT record used for the DNS challenge | +| `SAKURACLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `SAKURACLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `SAKURACLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `SAKURACLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_scaleway.md b/docs/content/dns/zz_gen_scaleway.md index 111d18a42..4033a9bd6 100644 --- a/docs/content/dns/zz_gen_scaleway.md +++ b/docs/content/dns/zz_gen_scaleway.md @@ -27,7 +27,7 @@ Here is an example bash command using the Scaleway provider: ```bash SCW_SECRET_KEY=xxxxxxx-xxxxx-xxxx-xxx-xxxxxx \ -lego --email you@example.com --dns scaleway -d '*.example.com' -d example.com run +lego --dns scaleway -d '*.example.com' -d example.com run ``` @@ -49,9 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `SCW_ACCESS_KEY` | Access key | -| `SCW_POLLING_INTERVAL` | Time between DNS propagation check | -| `SCW_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `SCW_TTL` | The TTL of the TXT record used for the DNS challenge | +| `SCW_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `SCW_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `SCW_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `SCW_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_selectel.md b/docs/content/dns/zz_gen_selectel.md index 00e5b5bad..d994d6633 100644 --- a/docs/content/dns/zz_gen_selectel.md +++ b/docs/content/dns/zz_gen_selectel.md @@ -27,7 +27,7 @@ Here is an example bash command using the Selectel provider: ```bash SELECTEL_API_TOKEN=xxxxx \ -lego --email you@example.com --dns selectel -d '*.example.com' -d example.com run +lego --dns selectel -d '*.example.com' -d example.com run ``` @@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `SELECTEL_BASE_URL` | API endpoint URL | -| `SELECTEL_HTTP_TIMEOUT` | API request timeout | -| `SELECTEL_POLLING_INTERVAL` | Time between DNS propagation check | -| `SELECTEL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `SELECTEL_TTL` | The TTL of the TXT record used for the DNS challenge | +| `SELECTEL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `SELECTEL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `SELECTEL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `SELECTEL_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_selectelv2.md b/docs/content/dns/zz_gen_selectelv2.md index bb09241aa..0873d810c 100644 --- a/docs/content/dns/zz_gen_selectelv2.md +++ b/docs/content/dns/zz_gen_selectelv2.md @@ -30,7 +30,7 @@ SELECTELV2_USERNAME=trex \ SELECTELV2_PASSWORD=xxxxx \ SELECTELV2_ACCOUNT_ID=1234567 \ SELECTELV2_PROJECT_ID=111a11111aaa11aa1a11aaa11111aa1a \ -lego --email you@example.com --dns selectelv2 -d '*.example.com' -d example.com run +lego --dns selectelv2 -d '*.example.com' -d example.com run ``` @@ -53,11 +53,14 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| +| `SELECTELV2_AUTH_REGION` | Location for auth endpoint like ResellAPI or Keystone (default: 'ru-1') | +| `SELECTELV2_AUTH_URL` | Identity endpoint (defaul: 'https://cloud.api.selcloud.ru/identity/v3/') | | `SELECTELV2_BASE_URL` | API endpoint URL | -| `SELECTELV2_HTTP_TIMEOUT` | API request timeout | -| `SELECTELV2_POLLING_INTERVAL` | Time between DNS propagation check | -| `SELECTELV2_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `SELECTELV2_TTL` | The TTL of the TXT record used for the DNS challenge | +| `SELECTELV2_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `SELECTELV2_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | +| `SELECTELV2_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `SELECTELV2_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | +| `SELECTELV2_USER_DOMAIN_NAME` | To specify the domain name (account ID) where the user is located. (default: SELECTELV2_ACCOUNT_ID) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_selfhostde.md b/docs/content/dns/zz_gen_selfhostde.md index 81abe85c1..363f782e0 100644 --- a/docs/content/dns/zz_gen_selfhostde.md +++ b/docs/content/dns/zz_gen_selfhostde.md @@ -29,7 +29,7 @@ Here is an example bash command using the SelfHost.(de|eu) provider: SELFHOSTDE_USERNAME=xxx \ SELFHOSTDE_PASSWORD=yyy \ SELFHOSTDE_RECORDS_MAPPING=my.example.com:123 \ -lego --email you@example.com --dns selfhostde -d '*.example.com' -d example.com run +lego --dns selfhostde -d '*.example.com' -d example.com run ``` @@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `SELFHOSTDE_HTTP_TIMEOUT` | API request timeout | -| `SELFHOSTDE_POLLING_INTERVAL` | Time between DNS propagation check | -| `SELFHOSTDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `SELFHOSTDE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `SELFHOSTDE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `SELFHOSTDE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) | +| `SELFHOSTDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 240) | +| `SELFHOSTDE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_servercow.md b/docs/content/dns/zz_gen_servercow.md index ce47077df..7d00a6306 100644 --- a/docs/content/dns/zz_gen_servercow.md +++ b/docs/content/dns/zz_gen_servercow.md @@ -28,7 +28,7 @@ Here is an example bash command using the Servercow provider: ```bash SERVERCOW_USERNAME=xxxxxxxx \ SERVERCOW_PASSWORD=xxxxxxxx \ -lego --email you@example.com --dns servercow -d '*.example.com' -d example.com run +lego --dns servercow -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `SERVERCOW_HTTP_TIMEOUT` | API request timeout | -| `SERVERCOW_POLLING_INTERVAL` | Time between DNS propagation check | -| `SERVERCOW_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `SERVERCOW_TTL` | The TTL of the TXT record used for the DNS challenge | +| `SERVERCOW_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `SERVERCOW_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `SERVERCOW_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `SERVERCOW_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). @@ -62,7 +62,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information -- [API documentation](https://cp.servercow.de/client/plugin/support_manager/knowledgebase/view/34/dns-api-v1/7/) +- [API documentation](https://wiki.servercow.de/en/domains/dns_api/api-syntax/) diff --git a/docs/content/dns/zz_gen_shellrent.md b/docs/content/dns/zz_gen_shellrent.md index 1719e07c9..cbbc172e2 100644 --- a/docs/content/dns/zz_gen_shellrent.md +++ b/docs/content/dns/zz_gen_shellrent.md @@ -28,7 +28,7 @@ Here is an example bash command using the Shellrent provider: ```bash SHELLRENT_USERNAME=xxxx \ SHELLRENT_TOKEN=yyyy \ -lego --email you@example.com --dns shellrent -d '*.example.com' -d example.com run +lego --dns shellrent -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `SHELLRENT_HTTP_TIMEOUT` | API request timeout | -| `SHELLRENT_POLLING_INTERVAL` | Time between DNS propagation check | -| `SHELLRENT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `SHELLRENT_TTL` | The TTL of the TXT record used for the DNS challenge | +| `SHELLRENT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `SHELLRENT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `SHELLRENT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | +| `SHELLRENT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_simply.md b/docs/content/dns/zz_gen_simply.md index 1603ee53f..edfa14380 100644 --- a/docs/content/dns/zz_gen_simply.md +++ b/docs/content/dns/zz_gen_simply.md @@ -28,7 +28,7 @@ Here is an example bash command using the Simply.com provider: ```bash SIMPLY_ACCOUNT_NAME=xxxxxx \ SIMPLY_API_KEY=yyyyyy \ -lego --email you@example.com --dns simply -d '*.example.com' -d example.com run +lego --dns simply -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `SIMPLY_HTTP_TIMEOUT` | API request timeout | -| `SIMPLY_POLLING_INTERVAL` | Time between DNS propagation check | -| `SIMPLY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `SIMPLY_TTL` | The TTL of the TXT record used for the DNS challenge | +| `SIMPLY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `SIMPLY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `SIMPLY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | +| `SIMPLY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_sonic.md b/docs/content/dns/zz_gen_sonic.md index 2adb435a9..20729bc1a 100644 --- a/docs/content/dns/zz_gen_sonic.md +++ b/docs/content/dns/zz_gen_sonic.md @@ -28,7 +28,7 @@ Here is an example bash command using the Sonic provider: ```bash SONIC_USER_ID=12345 \ SONIC_API_KEY=4d6fbf2f9ab0fa11697470918d37625851fc0c51 \ -lego --email you@example.com --dns sonic -d '*.example.com' -d example.com run +lego --dns sonic -d '*.example.com' -d example.com run ``` @@ -49,11 +49,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `SONIC_HTTP_TIMEOUT` | API request timeout | -| `SONIC_POLLING_INTERVAL` | Time between DNS propagation check | -| `SONIC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `SONIC_SEQUENCE_INTERVAL` | Time between sequential requests | -| `SONIC_TTL` | The TTL of the TXT record used for the DNS challenge | +| `SONIC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `SONIC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `SONIC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `SONIC_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | +| `SONIC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_spaceship.md b/docs/content/dns/zz_gen_spaceship.md new file mode 100644 index 000000000..9f3b51e43 --- /dev/null +++ b/docs/content/dns/zz_gen_spaceship.md @@ -0,0 +1,69 @@ +--- +title: "Spaceship" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: spaceship +dnsprovider: + since: "v4.22.0" + code: "spaceship" + url: "https://www.spaceship.com/" +--- + + + + + + +Configuration for [Spaceship](https://www.spaceship.com/). + + + + +- Code: `spaceship` +- Since: v4.22.0 + + +Here is an example bash command using the Spaceship provider: + +```bash +SPACESHIP_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +SPACESHIP_API_SECRET="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns spaceship -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `SPACESHIP_API_KEY` | API key | +| `SPACESHIP_API_SECRET` | API secret | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `SPACESHIP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `SPACESHIP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `SPACESHIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `SPACESHIP_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://docs.spaceship.dev/#tag/DNS-records) + + + + diff --git a/docs/content/dns/zz_gen_stackpath.md b/docs/content/dns/zz_gen_stackpath.md index cbafa4289..b881176f4 100644 --- a/docs/content/dns/zz_gen_stackpath.md +++ b/docs/content/dns/zz_gen_stackpath.md @@ -29,7 +29,7 @@ Here is an example bash command using the Stackpath provider: STACKPATH_CLIENT_ID=xxxxx \ STACKPATH_CLIENT_SECRET=yyyyy \ STACKPATH_STACK_ID=zzzzz \ -lego --email you@example.com --dns stackpath -d '*.example.com' -d example.com run +lego --dns stackpath -d '*.example.com' -d example.com run ``` @@ -51,9 +51,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `STACKPATH_POLLING_INTERVAL` | Time between DNS propagation check | -| `STACKPATH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `STACKPATH_TTL` | The TTL of the TXT record used for the DNS challenge | +| `STACKPATH_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `STACKPATH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `STACKPATH_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_syse.md b/docs/content/dns/zz_gen_syse.md new file mode 100644 index 000000000..a1a952bc5 --- /dev/null +++ b/docs/content/dns/zz_gen_syse.md @@ -0,0 +1,70 @@ +--- +title: "Syse" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: syse +dnsprovider: + since: "v4.30.0" + code: "syse" + url: "https://www.syse.no/" +--- + + + + + + +Configuration for [Syse](https://www.syse.no/). + + + + +- Code: `syse` +- Since: v4.30.0 + + +Here is an example bash command using the Syse provider: + +```bash +SYSE_CREDENTIALS=example.com:password \ +lego --dns syse -d '*.example.com' -d example.com run + +SYSE_CREDENTIALS=example.org:password1,example.com:password2 \ +lego --dns syse -d '*.example.org' -d example.org -d '*.example.com' -d example.com +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `SYSE_CREDENTIALS` | Comma-separated list of `zone:password` credential pairs | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `SYSE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `SYSE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `SYSE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 1200) | +| `SYSE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://www.syse.no/api/dns) + + + + diff --git a/docs/content/dns/zz_gen_technitium.md b/docs/content/dns/zz_gen_technitium.md index ecfa204ce..ff7f2e6ed 100644 --- a/docs/content/dns/zz_gen_technitium.md +++ b/docs/content/dns/zz_gen_technitium.md @@ -28,7 +28,7 @@ Here is an example bash command using the Technitium provider: ```bash TECHNITIUM_SERVER_BASE_URL="https://localhost:5380" \ TECHNITIUM_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ -lego --email you@example.com --dns technitium -d '*.example.com' -d example.com run +lego --dns technitium -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `TECHNITIUM_HTTP_TIMEOUT` | API request timeout | -| `TECHNITIUM_POLLING_INTERVAL` | Time between DNS propagation check | -| `TECHNITIUM_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `TECHNITIUM_TTL` | The TTL of the TXT record used for the DNS challenge | +| `TECHNITIUM_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `TECHNITIUM_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `TECHNITIUM_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `TECHNITIUM_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_tencentcloud.md b/docs/content/dns/zz_gen_tencentcloud.md index bc93c225e..178ffcf43 100644 --- a/docs/content/dns/zz_gen_tencentcloud.md +++ b/docs/content/dns/zz_gen_tencentcloud.md @@ -6,7 +6,7 @@ slug: tencentcloud dnsprovider: since: "v4.6.0" code: "tencentcloud" - url: "https://cloud.tencent.com/product/cns" + url: "https://cloud.tencent.com/product/dns" --- @@ -14,7 +14,7 @@ dnsprovider: -Configuration for [Tencent Cloud DNS](https://cloud.tencent.com/product/cns). +Configuration for [Tencent Cloud DNS](https://cloud.tencent.com/product/dns). @@ -28,7 +28,7 @@ Here is an example bash command using the Tencent Cloud DNS provider: ```bash TENCENTCLOUD_SECRET_ID=abcdefghijklmnopqrstuvwx \ TENCENTCLOUD_SECRET_KEY=your-secret-key \ -lego --email you@example.com --dns tencentcloud -d '*.example.com' -d example.com run +lego --dns tencentcloud -d '*.example.com' -d example.com run ``` @@ -49,12 +49,12 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `TENCENTCLOUD_HTTP_TIMEOUT` | API request timeout | -| `TENCENTCLOUD_POLLING_INTERVAL` | Time between DNS propagation check | -| `TENCENTCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `TENCENTCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `TENCENTCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `TENCENTCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `TENCENTCLOUD_REGION` | Region | | `TENCENTCLOUD_SESSION_TOKEN` | Access Key token | -| `TENCENTCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge | +| `TENCENTCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_timewebcloud.md b/docs/content/dns/zz_gen_timewebcloud.md index e933043a4..83d5b831b 100644 --- a/docs/content/dns/zz_gen_timewebcloud.md +++ b/docs/content/dns/zz_gen_timewebcloud.md @@ -27,7 +27,7 @@ Here is an example bash command using the Timeweb Cloud provider: ```bash TIMEWEBCLOUD_AUTH_TOKEN=xxxxxx \ -lego --email you@example.com --dns timewebcloud -d '*.example.com' -d example.com run +lego --dns timewebcloud -d '*.example.com' -d example.com run ``` @@ -47,9 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `TIMEWEBCLOUD_HTTP_TIMEOUT` | API request timeout | -| `TIMEWEBCLOUD_POLLING_INTERVAL` | Time between DNS propagation check | -| `TIMEWEBCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `TIMEWEBCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `TIMEWEBCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `TIMEWEBCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_todaynic.md b/docs/content/dns/zz_gen_todaynic.md new file mode 100644 index 000000000..7b06c012d --- /dev/null +++ b/docs/content/dns/zz_gen_todaynic.md @@ -0,0 +1,69 @@ +--- +title: "TodayNIC/时代互联" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: todaynic +dnsprovider: + since: "v4.32.0" + code: "todaynic" + url: "https://www.todaynic.com/" +--- + + + + + + +Configuration for [TodayNIC/时代互联](https://www.todaynic.com/). + + + + +- Code: `todaynic` +- Since: v4.32.0 + + +Here is an example bash command using the TodayNIC/时代互联 provider: + +```bash +TODAYNIC_AUTH_USER_ID="xxx" \ +TODAYNIC_API_KEY="yyy" \ +lego --dns todaynic -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `TODAYNIC_API_KEY` | API key | +| `TODAYNIC_AUTH_USER_ID` | account ID | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `TODAYNIC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `TODAYNIC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `TODAYNIC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `TODAYNIC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://www.todaynic.com/partner/mode_Http_Api_detail.php) + + + + diff --git a/docs/content/dns/zz_gen_transip.md b/docs/content/dns/zz_gen_transip.md index 64db62dc6..a66a25879 100644 --- a/docs/content/dns/zz_gen_transip.md +++ b/docs/content/dns/zz_gen_transip.md @@ -28,7 +28,7 @@ Here is an example bash command using the TransIP provider: ```bash TRANSIP_ACCOUNT_NAME = "Account name" \ TRANSIP_PRIVATE_KEY_PATH = "transip.key" \ -lego --email you@example.com --dns transip -d '*.example.com' -d example.com run +lego --dns transip -d '*.example.com' -d example.com run ``` @@ -49,9 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `TRANSIP_POLLING_INTERVAL` | Time between DNS propagation check | -| `TRANSIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `TRANSIP_TTL` | The TTL of the TXT record used for the DNS challenge | +| `TRANSIP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `TRANSIP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `TRANSIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) | +| `TRANSIP_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 10) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_ultradns.md b/docs/content/dns/zz_gen_ultradns.md index 36a233ae2..d6d89c77b 100644 --- a/docs/content/dns/zz_gen_ultradns.md +++ b/docs/content/dns/zz_gen_ultradns.md @@ -28,7 +28,7 @@ Here is an example bash command using the Ultradns provider: ```bash ULTRADNS_USERNAME=username \ ULTRADNS_PASSWORD=password \ -lego --email you@example.com --dns ultradns -d '*.example.com' -d example.com run +lego --dns ultradns -d '*.example.com' -d example.com run ``` @@ -50,9 +50,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `ULTRADNS_ENDPOINT` | API endpoint URL, defaults to https://api.ultradns.com/ | -| `ULTRADNS_POLLING_INTERVAL` | Time between DNS propagation check | -| `ULTRADNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `ULTRADNS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `ULTRADNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) | +| `ULTRADNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `ULTRADNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_uniteddomains.md b/docs/content/dns/zz_gen_uniteddomains.md new file mode 100644 index 000000000..e837644d5 --- /dev/null +++ b/docs/content/dns/zz_gen_uniteddomains.md @@ -0,0 +1,67 @@ +--- +title: "United-Domains" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: uniteddomains +dnsprovider: + since: "v4.29.0" + code: "uniteddomains" + url: "https://www.united-domains.de/" +--- + + + + + + +Configuration for [United-Domains](https://www.united-domains.de/). + + + + +- Code: `uniteddomains` +- Since: v4.29.0 + + +Here is an example bash command using the United-Domains provider: + +```bash +UNITEDDOMAINS_API_KEY=xxxxxxxx \ +lego --dns uniteddomains -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `UNITEDDOMAINS_API_KEY` | API key `.` https://www.united-domains.de/help/faq-article/getting-started-with-the-united-domains-dns-api/ | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `UNITEDDOMAINS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `UNITEDDOMAINS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `UNITEDDOMAINS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 900) | +| `UNITEDDOMAINS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://www.united-domains.de/dns-apidoc/) + + + + diff --git a/docs/content/dns/zz_gen_variomedia.md b/docs/content/dns/zz_gen_variomedia.md index 5fc6dfea6..f9771c867 100644 --- a/docs/content/dns/zz_gen_variomedia.md +++ b/docs/content/dns/zz_gen_variomedia.md @@ -27,7 +27,7 @@ Here is an example bash command using the Variomedia provider: ```bash VARIOMEDIA_API_TOKEN=xxxx \ -lego --email you@example.com --dns variomedia -d '*.example.com' -d example.com run +lego --dns variomedia -d '*.example.com' -d example.com run ``` @@ -47,11 +47,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `VARIOMEDIA_HTTP_TIMEOUT` | API request timeout | -| `VARIOMEDIA_POLLING_INTERVAL` | Time between DNS propagation check | -| `VARIOMEDIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `VARIOMEDIA_SEQUENCE_INTERVAL` | Time between sequential requests | -| `VARIOMEDIA_TTL` | The TTL of the TXT record used for the DNS challenge | +| `VARIOMEDIA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `VARIOMEDIA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `VARIOMEDIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `VARIOMEDIA_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | +| `VARIOMEDIA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_vegadns.md b/docs/content/dns/zz_gen_vegadns.md index b9fe43c1f..e06eebce7 100644 --- a/docs/content/dns/zz_gen_vegadns.md +++ b/docs/content/dns/zz_gen_vegadns.md @@ -46,9 +46,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `VEGADNS_POLLING_INTERVAL` | Time between DNS propagation check | -| `VEGADNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `VEGADNS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `VEGADNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 60) | +| `VEGADNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 720) | +| `VEGADNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 10) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_vercel.md b/docs/content/dns/zz_gen_vercel.md index e092b4fff..71f2eeed5 100644 --- a/docs/content/dns/zz_gen_vercel.md +++ b/docs/content/dns/zz_gen_vercel.md @@ -27,7 +27,7 @@ Here is an example bash command using the Vercel provider: ```bash VERCEL_API_TOKEN=xxxxxx \ -lego --email you@example.com --dns vercel -d '*.example.com' -d example.com run +lego --dns vercel -d '*.example.com' -d example.com run ``` @@ -47,11 +47,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `VERCEL_HTTP_TIMEOUT` | API request timeout | -| `VERCEL_POLLING_INTERVAL` | Time between DNS propagation check | -| `VERCEL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `VERCEL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `VERCEL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | +| `VERCEL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `VERCEL_TEAM_ID` | Team ID (ex: team_xxxxxxxxxxxxxxxxxxxxxxxx) | -| `VERCEL_TTL` | The TTL of the TXT record used for the DNS challenge | +| `VERCEL_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_versio.md b/docs/content/dns/zz_gen_versio.md index 3941605c4..5d2cc0118 100644 --- a/docs/content/dns/zz_gen_versio.md +++ b/docs/content/dns/zz_gen_versio.md @@ -28,7 +28,7 @@ Here is an example bash command using the Versio.[nl|eu|uk] provider: ```bash VERSIO_USERNAME= \ VERSIO_PASSWORD= \ -lego --email you@example.com --dns versio -d '*.example.com' -d example.com run +lego --dns versio -d '*.example.com' -d example.com run ``` @@ -50,11 +50,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `VERSIO_ENDPOINT` | The endpoint URL of the API Server | -| `VERSIO_HTTP_TIMEOUT` | API request timeout | -| `VERSIO_POLLING_INTERVAL` | Time between DNS propagation check | -| `VERSIO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `VERSIO_SEQUENCE_INTERVAL` | Time between sequential requests, default 60s | -| `VERSIO_TTL` | The TTL of the TXT record used for the DNS challenge | +| `VERSIO_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `VERSIO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | +| `VERSIO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `VERSIO_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | +| `VERSIO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_vinyldns.md b/docs/content/dns/zz_gen_vinyldns.md index 92e0138dd..3280d6f0a 100644 --- a/docs/content/dns/zz_gen_vinyldns.md +++ b/docs/content/dns/zz_gen_vinyldns.md @@ -29,7 +29,7 @@ Here is an example bash command using the VinylDNS provider: VINYLDNS_ACCESS_KEY=xxxxxx \ VINYLDNS_SECRET_KEY=yyyyy \ VINYLDNS_HOST=https://api.vinyldns.example.org:9443 \ -lego --email you@example.com --dns vinyldns -d '*.example.com' -d example.com run +lego --dns vinyldns -d '*.example.com' -d example.com run ``` @@ -51,9 +51,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `VINYLDNS_POLLING_INTERVAL` | Time between DNS propagation check | -| `VINYLDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `VINYLDNS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `VINYLDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `VINYLDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) | +| `VINYLDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `VINYLDNS_QUOTE_VALUE` | Adds quotes around the TXT record value (Default: false) | +| `VINYLDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 30) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_virtualname.md b/docs/content/dns/zz_gen_virtualname.md new file mode 100644 index 000000000..a00e5105f --- /dev/null +++ b/docs/content/dns/zz_gen_virtualname.md @@ -0,0 +1,67 @@ +--- +title: "Virtualname" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: virtualname +dnsprovider: + since: "v4.30.0" + code: "virtualname" + url: "https://www.virtualname.es/" +--- + + + + + + +Configuration for [Virtualname](https://www.virtualname.es/). + + + + +- Code: `virtualname` +- Since: v4.30.0 + + +Here is an example bash command using the Virtualname provider: + +```bash +VIRTUALNAME_TOKEN=xxxxxx \ +lego --dns virtualname -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `VIRTUALNAME_TOKEN` | API token | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `VIRTUALNAME_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `VIRTUALNAME_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `VIRTUALNAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | +| `VIRTUALNAME_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://developers.virtualname.net/#dns) + + + + diff --git a/docs/content/dns/zz_gen_vkcloud.md b/docs/content/dns/zz_gen_vkcloud.md index d3c33e9c2..76fd557a5 100644 --- a/docs/content/dns/zz_gen_vkcloud.md +++ b/docs/content/dns/zz_gen_vkcloud.md @@ -29,7 +29,7 @@ Here is an example bash command using the VK Cloud provider: VK_CLOUD_PROJECT_ID="" \ VK_CLOUD_USERNAME="" \ VK_CLOUD_PASSWORD="" \ -lego --email you@example.com --dns vkcloud -d '*.example.com' -d example.com run +lego --dns vkcloud -d '*.example.com' -d example.com run ``` @@ -54,9 +54,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | `VK_CLOUD_DNS_ENDPOINT` | URL of DNS API. Defaults to https://mcs.mail.ru/public-dns but can be changed for usage with private clouds | | `VK_CLOUD_DOMAIN_NAME` | Openstack users domain name. Defaults to `users` but can be changed for usage with private clouds | | `VK_CLOUD_IDENTITY_ENDPOINT` | URL of OpenStack Auth API, Defaults to https://infra.mail.ru:35357/v3/ but can be changed for usage with private clouds | -| `VK_CLOUD_POLLING_INTERVAL` | Time between DNS propagation check | -| `VK_CLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `VK_CLOUD_TTL` | The TTL of the TXT record used for the DNS challenge | +| `VK_CLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `VK_CLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `VK_CLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_volcengine.md b/docs/content/dns/zz_gen_volcengine.md index a1eb5d4ec..587ce1e74 100644 --- a/docs/content/dns/zz_gen_volcengine.md +++ b/docs/content/dns/zz_gen_volcengine.md @@ -28,7 +28,7 @@ Here is an example bash command using the Volcano Engine/火山引擎 provider: ```bash VOLC_ACCESSKEY=xxx \ VOLC_SECRETKEY=yyy \ -lego --email you@example.com --dns volcengine -d '*.example.com' -d example.com run +lego --dns volcengine -d '*.example.com' -d example.com run ``` @@ -50,12 +50,12 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `VOLC_HOST` | API host | -| `VOLC_HTTP_TIMEOUT` | API request timeout | -| `VOLC_POLLING_INTERVAL` | Time between DNS propagation check | -| `VOLC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `VOLC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 15) | +| `VOLC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `VOLC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 240) | | `VOLC_REGION` | Region | | `VOLC_SCHEME` | API scheme | -| `VOLC_TTL` | The TTL of the TXT record used for the DNS challenge | +| `VOLC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_vscale.md b/docs/content/dns/zz_gen_vscale.md index 696d404d8..c33e2f7b5 100644 --- a/docs/content/dns/zz_gen_vscale.md +++ b/docs/content/dns/zz_gen_vscale.md @@ -27,7 +27,7 @@ Here is an example bash command using the Vscale provider: ```bash VSCALE_API_TOKEN=xxxxx \ -lego --email you@example.com --dns vscale -d '*.example.com' -d example.com run +lego --dns vscale -d '*.example.com' -d example.com run ``` @@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `VSCALE_BASE_URL` | API endpoint URL | -| `VSCALE_HTTP_TIMEOUT` | API request timeout | -| `VSCALE_POLLING_INTERVAL` | Time between DNS propagation check | -| `VSCALE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `VSCALE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `VSCALE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `VSCALE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `VSCALE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `VSCALE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_vultr.md b/docs/content/dns/zz_gen_vultr.md index 0334a69ad..4160fbcf3 100644 --- a/docs/content/dns/zz_gen_vultr.md +++ b/docs/content/dns/zz_gen_vultr.md @@ -27,7 +27,7 @@ Here is an example bash command using the Vultr provider: ```bash VULTR_API_KEY=xxxxx \ -lego --email you@example.com --dns vultr -d '*.example.com' -d example.com run +lego --dns vultr -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `VULTR_HTTP_TIMEOUT` | API request timeout | -| `VULTR_POLLING_INTERVAL` | Time between DNS propagation check | -| `VULTR_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `VULTR_TTL` | The TTL of the TXT record used for the DNS challenge | +| `VULTR_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `VULTR_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `VULTR_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `VULTR_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_webnames.md b/docs/content/dns/zz_gen_webnames.md index 2fdc09cd3..cad02c287 100644 --- a/docs/content/dns/zz_gen_webnames.md +++ b/docs/content/dns/zz_gen_webnames.md @@ -1,5 +1,5 @@ --- -title: "Webnames" +title: "webnames.ru" date: 2019-03-03T16:39:46+01:00 draft: false slug: webnames @@ -14,7 +14,7 @@ dnsprovider: -Configuration for [Webnames](https://www.webnames.ru/). +Configuration for [webnames.ru](https://www.webnames.ru/). @@ -23,11 +23,11 @@ Configuration for [Webnames](https://www.webnames.ru/). - Since: v4.15.0 -Here is an example bash command using the Webnames provider: +Here is an example bash command using the webnames.ru provider: ```bash -WEBNAMES_API_KEY=xxxxxx \ -lego --email you@example.com --dns webnames -d '*.example.com' -d example.com run +WEBNAMESRU_API_KEY=xxxxxx \ +lego --dns webnamesru -d '*.example.com' -d example.com run ``` @@ -37,7 +37,7 @@ lego --email you@example.com --dns webnames -d '*.example.com' -d example.com ru | Environment Variable Name | Description | |-----------------------|-------------| -| `WEBNAMES_API_KEY` | Domain API key | +| `WEBNAMESRU_API_KEY` | Domain API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). @@ -47,10 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `WEBNAMES_HTTP_TIMEOUT` | API request timeout | -| `WEBNAMES_POLLING_INTERVAL` | Time between DNS propagation check | -| `WEBNAMES_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `WEBNAMES_TTL` | The TTL of the TXT record used for the DNS challenge | +| `WEBNAMESRU_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `WEBNAMESRU_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `WEBNAMESRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_webnamesca.md b/docs/content/dns/zz_gen_webnamesca.md new file mode 100644 index 000000000..4a7d3794f --- /dev/null +++ b/docs/content/dns/zz_gen_webnamesca.md @@ -0,0 +1,69 @@ +--- +title: "webnames.ca" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: webnamesca +dnsprovider: + since: "v4.28.0" + code: "webnamesca" + url: "https://www.webnames.ca/" +--- + + + + + + +Configuration for [webnames.ca](https://www.webnames.ca/). + + + + +- Code: `webnamesca` +- Since: v4.28.0 + + +Here is an example bash command using the webnames.ca provider: + +```bash +WEBNAMESCA_API_USER="xxx" \ +WEBNAMESCA_API_KEY="yyy" \ +lego --dns webnamesca -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `WEBNAMESCA_API_KEY` | API key | +| `WEBNAMESCA_API_USER` | API username | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `WEBNAMESCA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `WEBNAMESCA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `WEBNAMESCA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `WEBNAMESCA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://www.webnames.ca/_/swagger/index.html) + + + + diff --git a/docs/content/dns/zz_gen_websupport.md b/docs/content/dns/zz_gen_websupport.md index c48181a54..67ae394d7 100644 --- a/docs/content/dns/zz_gen_websupport.md +++ b/docs/content/dns/zz_gen_websupport.md @@ -28,7 +28,7 @@ Here is an example bash command using the Websupport provider: ```bash WEBSUPPORT_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ WEBSUPPORT_SECRET="yyyyyyyyyyyyyyyyyyyyy" \ -lego --email you@example.com --dns websupport -d '*.example.com' -d example.com run +lego --dns websupport -d '*.example.com' -d example.com run ``` @@ -49,11 +49,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `WEBSUPPORT_HTTP_TIMEOUT` | API request timeout | -| `WEBSUPPORT_POLLING_INTERVAL` | Time between DNS propagation check | -| `WEBSUPPORT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `WEBSUPPORT_SEQUENCE_INTERVAL` | Time between sequential requests | -| `WEBSUPPORT_TTL` | The TTL of the TXT record used for the DNS challenge | +| `WEBSUPPORT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `WEBSUPPORT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `WEBSUPPORT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `WEBSUPPORT_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | +| `WEBSUPPORT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). @@ -63,7 +63,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information -- [API documentation](https://rest.websupport.sk/docs/v1.zone) +- [API documentation](https://rest.websupport.sk/v2/docs) diff --git a/docs/content/dns/zz_gen_wedos.md b/docs/content/dns/zz_gen_wedos.md index 1762cf4ca..16139f4d4 100644 --- a/docs/content/dns/zz_gen_wedos.md +++ b/docs/content/dns/zz_gen_wedos.md @@ -28,7 +28,7 @@ Here is an example bash command using the WEDOS provider: ```bash WEDOS_USERNAME=xxxxxxxx \ WEDOS_WAPI_PASSWORD=xxxxxxxx \ -lego --email you@example.com --dns wedos -d '*.example.com' -d example.com run +lego --dns wedos -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `WEDOS_HTTP_TIMEOUT` | API request timeout | -| `WEDOS_POLLING_INTERVAL` | Time between DNS propagation check | -| `WEDOS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `WEDOS_TTL` | The TTL of the TXT record used for the DNS challenge | +| `WEDOS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `WEDOS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `WEDOS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) | +| `WEDOS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_westcn.md b/docs/content/dns/zz_gen_westcn.md new file mode 100644 index 000000000..a5523b955 --- /dev/null +++ b/docs/content/dns/zz_gen_westcn.md @@ -0,0 +1,69 @@ +--- +title: "West.cn/西部数码" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: westcn +dnsprovider: + since: "v4.21.0" + code: "westcn" + url: "https://www.west.cn" +--- + + + + + + +Configuration for [West.cn/西部数码](https://www.west.cn). + + + + +- Code: `westcn` +- Since: v4.21.0 + + +Here is an example bash command using the West.cn/西部数码 provider: + +```bash +WESTCN_USERNAME="xxx" \ +WESTCN_PASSWORD="yyy" \ +lego --dns westcn -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `WESTCN_PASSWORD` | API password | +| `WESTCN_USERNAME` | Username | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `WESTCN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `WESTCN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | +| `WESTCN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `WESTCN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://www.west.cn/CustomerCenter/doc/domain_v2.html) + + + + diff --git a/docs/content/dns/zz_gen_yandex.md b/docs/content/dns/zz_gen_yandex.md index 60b8a0ac3..4a1cf1f99 100644 --- a/docs/content/dns/zz_gen_yandex.md +++ b/docs/content/dns/zz_gen_yandex.md @@ -27,7 +27,7 @@ Here is an example bash command using the Yandex PDD provider: ```bash YANDEX_PDD_TOKEN= \ -lego --email you@example.com --dns yandex -d '*.example.com' -d example.com run +lego --dns yandex -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `YANDEX_HTTP_TIMEOUT` | API request timeout | -| `YANDEX_POLLING_INTERVAL` | Time between DNS propagation check | -| `YANDEX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `YANDEX_TTL` | The TTL of the TXT record used for the DNS challenge | +| `YANDEX_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `YANDEX_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `YANDEX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `YANDEX_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_yandex360.md b/docs/content/dns/zz_gen_yandex360.md index 04eeab45c..d831fdfc2 100644 --- a/docs/content/dns/zz_gen_yandex360.md +++ b/docs/content/dns/zz_gen_yandex360.md @@ -28,7 +28,7 @@ Here is an example bash command using the Yandex 360 provider: ```bash YANDEX360_OAUTH_TOKEN= \ YANDEX360_ORG_ID= \ -lego --email you@example.com --dns yandex360 -d '*.example.com' -d example.com run +lego --dns yandex360 -d '*.example.com' -d example.com run ``` @@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `YANDEX360_HTTP_TIMEOUT` | API request timeout | -| `YANDEX360_POLLING_INTERVAL` | Time between DNS propagation check | -| `YANDEX360_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `YANDEX360_TTL` | The TTL of the TXT record used for the DNS challenge | +| `YANDEX360_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `YANDEX360_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `YANDEX360_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `YANDEX360_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_yandexcloud.md b/docs/content/dns/zz_gen_yandexcloud.md index 0831e8c49..0564e93d2 100644 --- a/docs/content/dns/zz_gen_yandexcloud.md +++ b/docs/content/dns/zz_gen_yandexcloud.md @@ -28,7 +28,7 @@ Here is an example bash command using the Yandex Cloud provider: ```bash YANDEX_CLOUD_IAM_TOKEN= \ YANDEX_CLOUD_FOLDER_ID= \ -lego --email you@example.com --dns yandexcloud -d '*.example.com' -d example.com run +lego --dns yandexcloud -d '*.example.com' -d example.com run # --- @@ -41,7 +41,7 @@ YANDEX_CLOUD_IAM_TOKEN=$(echo '{ \ "private_key": "-----BEGIN PRIVATE KEY----------END PRIVATE KEY-----" \ }' | base64) \ YANDEX_CLOUD_FOLDER_ID= \ -lego --email you@example.com --dns yandexcloud -d '*.example.com' -d example.com run +lego --dns yandexcloud -d '*.example.com' -d example.com run ``` @@ -62,9 +62,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `YANDEX_CLOUD_POLLING_INTERVAL` | Time between DNS propagation check | -| `YANDEX_CLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `YANDEX_CLOUD_TTL` | The TTL of the TXT record used for the DNS challenge | +| `YANDEX_CLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `YANDEX_CLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `YANDEX_CLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_zoneedit.md b/docs/content/dns/zz_gen_zoneedit.md new file mode 100644 index 000000000..c7f88b3fe --- /dev/null +++ b/docs/content/dns/zz_gen_zoneedit.md @@ -0,0 +1,68 @@ +--- +title: "ZoneEdit" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: zoneedit +dnsprovider: + since: "v4.25.0" + code: "zoneedit" + url: "https://www.zoneedit.com" +--- + + + + + + +Configuration for [ZoneEdit](https://www.zoneedit.com). + + + + +- Code: `zoneedit` +- Since: v4.25.0 + + +Here is an example bash command using the ZoneEdit provider: + +```bash +ZONEEDIT_USER="xxxxxxxxxxxxxxxxxxxxx" \ +ZONEEDIT_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns zoneedit -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `ZONEEDIT_AUTH_TOKEN` | Authentication token | +| `ZONEEDIT_USER` | User ID | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `ZONEEDIT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `ZONEEDIT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `ZONEEDIT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://support.zoneedit.com/en/knowledgebase/article/changes-to-dynamic-dns) + + + + diff --git a/docs/content/dns/zz_gen_zoneee.md b/docs/content/dns/zz_gen_zoneee.md index a6df03b56..65678a3dc 100644 --- a/docs/content/dns/zz_gen_zoneee.md +++ b/docs/content/dns/zz_gen_zoneee.md @@ -28,7 +28,7 @@ Here is an example bash command using the Zone.ee provider: ```bash ZONEEE_API_USER=xxxxx \ ZONEEE_API_KEY=yyyyy \ -lego --email you@example.com --dns zoneee -d '*.example.com' -d example.com run +lego --dns zoneee -d '*.example.com' -d example.com run ``` @@ -50,10 +50,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `ZONEEE_ENDPOINT` | API endpoint URL | -| `ZONEEE_HTTP_TIMEOUT` | API request timeout | -| `ZONEEE_POLLING_INTERVAL` | Time between DNS propagation check | -| `ZONEEE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `ZONEEE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `ZONEEE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `ZONEEE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | +| `ZONEEE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/dns/zz_gen_zonomi.md b/docs/content/dns/zz_gen_zonomi.md index 51c25d95d..fd8757f82 100644 --- a/docs/content/dns/zz_gen_zonomi.md +++ b/docs/content/dns/zz_gen_zonomi.md @@ -27,7 +27,7 @@ Here is an example bash command using the Zonomi provider: ```bash ZONOMI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns zonomi -d '*.example.com' -d example.com run +lego --dns zonomi -d '*.example.com' -d example.com run ``` @@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| -| `ZONOMI_HTTP_TIMEOUT` | API request timeout | -| `ZONOMI_POLLING_INTERVAL` | Time between DNS propagation check | -| `ZONOMI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | -| `ZONOMI_TTL` | The TTL of the TXT record used for the DNS challenge | +| `ZONOMI_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `ZONOMI_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `ZONOMI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `ZONOMI_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). diff --git a/docs/content/usage/cli/Obtain-a-Certificate.md b/docs/content/usage/cli/Obtain-a-Certificate.md index c92f4ecf0..c7f25dfc0 100644 --- a/docs/content/usage/cli/Obtain-a-Certificate.md +++ b/docs/content/usage/cli/Obtain-a-Certificate.md @@ -58,6 +58,21 @@ GANDI_API_KEY=xxx \ lego --email "you@example.com" --dns gandi --domains "example.org" --domains "*.example.org" run ``` +{{% notice title="For a zone that has multiple SOAs" icon="info-circle" %}} + +This can often be found where your DNS provider has a zone entry for an internal network (i.e. a corporate network, or home LAN) as well as the public internet. +In this case, point lego at an external authoritative server for the zone using the additional parameter `--dns.resolvers`. + +```bash +GANDI_API_KEY=xxx \ +lego --email "you@example.com" --dns gandi --dns.resolvers 9.9.9.9:53 --domains "example.org" --domains "*.example.org" run + +``` + +[More information about resolvers.]({{% ref "options#dns-resolvers-and-challenge-verification" %}}) + +{{% /notice %}} + ## Using a custom certificate signing request (CSR) diff --git a/docs/content/usage/cli/Options.md b/docs/content/usage/cli/Options.md index a6484de23..7b5df027a 100644 --- a/docs/content/usage/cli/Options.md +++ b/docs/content/usage/cli/Options.md @@ -142,3 +142,32 @@ Example: ```bash LEGO_DEBUG_CLIENT_VERBOSE_ERROR=true ``` + +### LEGO_DEBUG_DNS_API_HTTP_CLIENT + +> **⚠️ WARNING: This will expose credentials in the log output! ⚠️** +> +> Do not run this in production environments, or if you can't be sure that logs aren't accessed by third parties or tools (like log collectors). +> +> You have been warned. Here be dragons. + +The environment variable `LEGO_DEBUG_DNS_API_HTTP_CLIENT` allows debugging the DNS API interaction. +It will dump the full request and response to the log output. + +Some DNS providers don't support this option. + +Example: + +```bash +LEGO_DEBUG_DNS_API_HTTP_CLIENT=true +``` + +### LEGO_DEBUG_ACME_HTTP_CLIENT + +The environment variable `LEGO_DEBUG_ACME_HTTP_CLIENT` allows debug the calls to the ACME server. + +Example: + +```bash +LEGO_DEBUG_ACME_HTTP_CLIENT=true +``` diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml index ad95fe40d..139143b17 100644 --- a/docs/data/zz_cli_help.toml +++ b/docs/data/zz_cli_help.toml @@ -22,7 +22,8 @@ GLOBAL OPTIONS: --domains value, -d value [ --domains value, -d value ] Add a domain to the process. Can be specified multiple times. --server value, -s value CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. (default: "https://acme-v02.api.letsencrypt.org/directory") [$LEGO_SERVER] --accept-tos, -a By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service. (default: false) - --email value, -m value Email used for registration and recovery contact. + --email value, -m value Email used for registration and recovery contact. [$LEGO_EMAIL] + --disable-cn Disable the use of the common name in the CSR. (default: false) --csr value, -c value Certificate signing request filename, if an external CSR is to be used. --eab Use External Account Binding for account registration. Requires --kid and --hmac. (default: false) [$LEGO_EAB] --kid value Key identifier from External CA. Used for External Account Binding. [$LEGO_EAB_KID] @@ -32,12 +33,14 @@ GLOBAL OPTIONS: --path value Directory to use for storing the data. (default: "./.lego") [$LEGO_PATH] --http Use the HTTP-01 challenge to solve challenges. Can be mixed with other types of challenges. (default: false) --http.port value Set the port and interface to use for HTTP-01 based challenges to listen on. Supported: interface:port or :port. (default: ":80") + --http.delay value Delay between the starts of the HTTP server (use for HTTP-01 based challenges) and the validation of the challenge. (default: 0s) --http.proxy-header value Validate against this HTTP header when solving HTTP-01 based challenges behind a reverse proxy. (default: "Host") --http.webroot value Set the webroot folder to use for HTTP-01 based challenges to write directly to the .well-known/acme-challenge file. This disables the built-in server and expects the given directory to be publicly served with access to .well-known/acme-challenge --http.memcached-host value [ --http.memcached-host value ] Set the memcached host(s) to use for HTTP-01 based challenges. Challenges will be written to all specified hosts. --http.s3-bucket value Set the S3 bucket name to use for HTTP-01 based challenges. Challenges will be written to the S3 bucket. --tls Use the TLS-ALPN-01 challenge to solve challenges. Can be mixed with other types of challenges. (default: false) --tls.port value Set the port and interface to use for TLS-ALPN-01 based challenges to listen on. Supported: interface:port or :port. (default: ":443") + --tls.delay value Delay between the start of the TLS listener (use for TLSALPN-01 based challenges) and the validation of the challenge. (default: 0s) --dns value Solve a DNS-01 challenge using the specified provider. Can be mixed with other types of challenges. Run 'lego dnshelp' for help on usage. --dns.disable-cp (deprecated) use dns.propagation-disable-ans instead. (default: false) --dns.propagation-disable-ans By setting this flag to true, disables the need to await propagation of the TXT record to all authoritative name servers. (default: false) @@ -71,9 +74,12 @@ OPTIONS: --must-staple Include the OCSP must staple TLS extension in the CSR and generated certificate. Only works if the CSR is generated by lego. (default: false) --not-before value Set the notBefore field in the certificate (RFC3339 format) --not-after value Set the notAfter field in the certificate (RFC3339 format) + --private-key value Path to private key (in PEM encoding) for the certificate. By default, the private key is generated. --preferred-chain value If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. If no match, the default offered chain will be used. + --profile value If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one. --always-deactivate-authorizations value Force the authorizations to be relinquished even if the certificate request was successful. --run-hook value Define a hook. The hook is executed when the certificates are effectively created. + --run-hook-timeout value Define the timeout for the hook execution. (default: 2m0s) --help, -h show help """ @@ -88,7 +94,8 @@ USAGE: OPTIONS: --days value The number of days left on a certificate to renew it. (default: 30) - --ari-disable Do not use the renewalInfo endpoint (draft-ietf-acme-ari) to check if a certificate should be renewed. (default: false) + --dynamic Compute dynamically, based on the lifetime of the certificate(s), when to renew: use 1/3rd of the lifetime left, or 1/2 of the lifetime for short-lived certificates). This supersedes --days and will be the default behavior in Lego v5. (default: false) + --ari-disable Do not use the renewalInfo endpoint (RFC9773) to check if a certificate should be renewed. (default: false) --ari-wait-to-renew-duration value The maximum duration you're willing to sleep for a renewal time returned by the renewalInfo endpoint. (default: 0s) --reuse-key Used to indicate you want to reuse your current private key for the new certificate. (default: false) --no-bundle Do not create a certificate bundle by adding the issuers certificate to the new certificate. (default: false) @@ -96,9 +103,12 @@ OPTIONS: --not-before value Set the notBefore field in the certificate (RFC3339 format) --not-after value Set the notAfter field in the certificate (RFC3339 format) --preferred-chain value If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. If no match, the default offered chain will be used. + --profile value If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one. --always-deactivate-authorizations value Force the authorizations to be relinquished even if the certificate request was successful. --renew-hook value Define a hook. The hook is executed only when the certificates are effectively renewed. + --renew-hook-timeout value Define the timeout for the hook execution. (default: 2m0s) --no-random-sleep Do not add a random sleep before the renewal. We do not recommend using this flag if you are doing your renewals in an automated way. (default: false) + --force-cert-domains Check and ensure that the cert's domain list matches those passed in the domains argument. (default: false) --help, -h show help """ @@ -142,7 +152,7 @@ To display the documentation for a specific DNS provider, run: $ lego dnshelp -c code Supported DNS providers: - acme-dns, alidns, allinkl, arvancloud, auroradns, autodns, azure, azuredns, bindman, bluecat, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, conoha, constellix, corenetworks, cpanel, derak, desec, designate, digitalocean, directadmin, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dynu, easydns, edgedns, efficientip, epik, exec, exoscale, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manual, metaname, mijnhost, mittwald, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, stackpath, technitium, tencentcloud, timewebcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, volcengine, vscale, vultr, webnames, websupport, wedos, yandex, yandex360, yandexcloud, zoneee, zonomi + acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, artfiles, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, czechia, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, eurodns, excedo, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, leaseweb, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, namesurfer, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi More information: https://go-acme.github.io/lego/dns """ diff --git a/docs/go.mod b/docs/go.mod index 5cb2add45..2240eb1e6 100644 --- a/docs/go.mod +++ b/docs/go.mod @@ -2,4 +2,4 @@ module github.com/go-acme/lego/docs go 1.20 -require github.com/McShelby/hugo-theme-relearn v0.0.0-20240802145348-259f21f89851 +require github.com/McShelby/hugo-theme-relearn v0.0.0-20250707094454-9803d5122ebb diff --git a/docs/go.sum b/docs/go.sum index 1ed963e87..b62d5c809 100644 --- a/docs/go.sum +++ b/docs/go.sum @@ -1,2 +1,2 @@ -github.com/McShelby/hugo-theme-relearn v0.0.0-20240802145348-259f21f89851 h1:JpmKIb1bRzuAcgnphwSb35Xz9rk/Alq19uRWVGSwScA= -github.com/McShelby/hugo-theme-relearn v0.0.0-20240802145348-259f21f89851/go.mod h1:mKQQdxZNIlLvAj8X3tMq+RzntIJSr9z7XdzuMomt0IM= +github.com/McShelby/hugo-theme-relearn v0.0.0-20250707094454-9803d5122ebb h1:iTGWOs8uKUaYmd7+wHRyPGXxt+SS5Bhvx2RRboYRXlI= +github.com/McShelby/hugo-theme-relearn v0.0.0-20250707094454-9803d5122ebb/go.mod h1:mKQQdxZNIlLvAj8X3tMq+RzntIJSr9z7XdzuMomt0IM= diff --git a/docs/hugo.toml b/docs/hugo.toml index a974cea73..fe076a306 100644 --- a/docs/hugo.toml +++ b/docs/hugo.toml @@ -2,47 +2,20 @@ baseURL = "https://go-acme.github.io/lego/" languageCode = "en-us" title = "Lego" -# Code highlighting settings -pygmentsCodefences = true -pygmentsCodeFencesGuesSsyntax = false -pygmentsOptions = "" -pygmentsStyle = "monokai" -# The monokai stylesheet is included in the base template. -pygmentsUseClasses = true - [permalinks] dns = "/dns/:slug/" [params] - # Prefix URL to edit current page. Will display an "Edit this page" button on top right hand corner of every page. - # Useful to give opportunity to people to create merge request for your doc. - # See the config.toml file from this documentation site to have an example. -# editURL = "" # Description of the site, will be used in meta information # description = "" # Shows a checkmark for visited pages on the menu showVisitedLinks = true - # Disable search function. It will hide search bar -# disableSearch = false - # Javascript and CSS cache are automatically busted when new version of site is generated. - # Set this to true to disable this behavior (some proxies don't handle well this optimization) -# disableAssetsBusting = false - # Set this to true to disable copy-to-clipboard button for inline code. -# disableInlineCopyToClipBoard = true - # A title for shortcuts in menu is set by default. Set this to true to disable it. -# disableShortcutsTitle = false - # When using mulitlingual website, disable the switch language button. -# disableLanguageSwitchingButton = false - # Hide breadcrumbs in the header and only show the current page title -# disableBreadcrumb = true - # Hide Next and Previous page buttons normally displayed full height beside content -# disableNextPrev = true - # Order sections in menu by "weight" or "title". Default to "weight" -# ordersectionsby = "weight" # Change default color scheme with a variant one. Can be "red", "blue", "green". themeVariant = "blue" custom_css = ["css/theme-custom.css"] disableLandingPageButton = true + hideAuthorEmail = true + hideAuthorName = true # Author of the site, will be used in meta information [params.author] @@ -71,7 +44,7 @@ pygmentsUseClasses = true weight = 12 [outputs] - home = [ "html", "rss", "search", "searchpage"] + home = ['html', 'rss', 'print'] [module] [[module.imports]] diff --git a/docs/static/.nojekyll b/docs/static/.nojekyll new file mode 100644 index 000000000..e69de29bb diff --git a/e2e/challenges_test.go b/e2e/challenges_test.go index cbf364c57..be1d23131 100644 --- a/e2e/challenges_test.go +++ b/e2e/challenges_test.go @@ -5,8 +5,10 @@ import ( "crypto/rand" "crypto/rsa" "crypto/x509" + "encoding/pem" "fmt" "os" + "path/filepath" "testing" "time" @@ -21,6 +23,18 @@ import ( "github.com/stretchr/testify/require" ) +const ( + testDomain1 = "acme.localhost" + testDomain2 = "lego.localhost" + testDomain3 = "acme.lego.localhost" + testDomain4 = "légô.localhost" +) + +const ( + testEmail1 = "lego@example.com" + testEmail2 = "acme@example.com" +) + var load = loader.EnvLoader{ PebbleOptions: &loader.CmdOption{ HealthCheckURL: "https://localhost:14000/dir", @@ -29,6 +43,7 @@ var load = loader.EnvLoader{ }, LegoOptions: []string{ "LEGO_CA_CERTIFICATES=./fixtures/certs/pebble.minica.pem", + "LEGO_DEBUG_ACME_HTTP_CLIENT=1", }, } @@ -37,7 +52,7 @@ func TestMain(m *testing.M) { } func TestHelp(t *testing.T) { - output, err := load.RunLego("-h") + output, err := load.RunLegoCombinedOutput("-h") if err != nil { fmt.Fprintf(os.Stderr, "%s\n", output) t.Fatal(err) @@ -49,18 +64,14 @@ func TestHelp(t *testing.T) { func TestChallengeHTTP_Run(t *testing.T) { loader.CleanLegoFiles() - output, err := load.RunLego( - "-m", "hubert@hubert.com", + err := load.RunLego( + "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", - "-d", "acme.wtf", + "-d", testDomain1, "--http", "--http.port", ":5002", "run") - - if len(output) > 0 { - fmt.Fprintf(os.Stdout, "%s\n", output) - } if err != nil { t.Fatal(err) } @@ -69,18 +80,14 @@ func TestChallengeHTTP_Run(t *testing.T) { func TestChallengeTLS_Run_Domains(t *testing.T) { loader.CleanLegoFiles() - output, err := load.RunLego( - "-m", "hubert@hubert.com", + err := load.RunLego( + "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", - "-d", "acme.wtf", + "-d", testDomain1, "--tls", "--tls.port", ":5001", "run") - - if len(output) > 0 { - fmt.Fprintf(os.Stdout, "%s\n", output) - } if err != nil { t.Fatal(err) } @@ -89,18 +96,14 @@ func TestChallengeTLS_Run_Domains(t *testing.T) { func TestChallengeTLS_Run_IP(t *testing.T) { loader.CleanLegoFiles() - output, err := load.RunLego( - "-m", "hubert@hubert.com", + err := load.RunLego( + "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-d", "127.0.0.1", "--tls", "--tls.port", ":5001", "run") - - if len(output) > 0 { - fmt.Fprintf(os.Stdout, "%s\n", output) - } if err != nil { t.Fatal(err) } @@ -109,18 +112,16 @@ func TestChallengeTLS_Run_IP(t *testing.T) { func TestChallengeTLS_Run_CSR(t *testing.T) { loader.CleanLegoFiles() - output, err := load.RunLego( - "-m", "hubert@hubert.com", + csrPath := createTestCSRFile(t, true) + + err := load.RunLego( + "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", - "-csr", "./fixtures/csr.raw", + "-csr", csrPath, "--tls", "--tls.port", ":5001", "run") - - if len(output) > 0 { - fmt.Fprintf(os.Stdout, "%s\n", output) - } if err != nil { t.Fatal(err) } @@ -129,18 +130,16 @@ func TestChallengeTLS_Run_CSR(t *testing.T) { func TestChallengeTLS_Run_CSR_PEM(t *testing.T) { loader.CleanLegoFiles() - output, err := load.RunLego( - "-m", "hubert@hubert.com", + csrPath := createTestCSRFile(t, false) + + err := load.RunLego( + "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", - "-csr", "./fixtures/csr.cert", + "-csr", csrPath, "--tls", "--tls.port", ":5001", "run") - - if len(output) > 0 { - fmt.Fprintf(os.Stdout, "%s\n", output) - } if err != nil { t.Fatal(err) } @@ -149,35 +148,27 @@ func TestChallengeTLS_Run_CSR_PEM(t *testing.T) { func TestChallengeTLS_Run_Revoke(t *testing.T) { loader.CleanLegoFiles() - output, err := load.RunLego( - "-m", "hubert@hubert.com", + err := load.RunLego( + "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", - "-d", "lego.wtf", - "-d", "acme.lego.wtf", + "-d", testDomain2, + "-d", testDomain3, "--tls", "--tls.port", ":5001", "run") - - if len(output) > 0 { - fmt.Fprintf(os.Stdout, "%s\n", output) - } if err != nil { t.Fatal(err) } - output, err = load.RunLego( - "-m", "hubert@hubert.com", + err = load.RunLego( + "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", - "-d", "lego.wtf", + "-d", testDomain2, "--tls", "--tls.port", ":5001", "revoke") - - if len(output) > 0 { - fmt.Fprintf(os.Stdout, "%s\n", output) - } if err != nil { t.Fatal(err) } @@ -186,34 +177,26 @@ func TestChallengeTLS_Run_Revoke(t *testing.T) { func TestChallengeTLS_Run_Revoke_Non_ASCII(t *testing.T) { loader.CleanLegoFiles() - output, err := load.RunLego( - "-m", "hubert@hubert.com", + err := load.RunLego( + "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", - "-d", "légô.wtf", + "-d", testDomain4, "--tls", "--tls.port", ":5001", "run") - - if len(output) > 0 { - fmt.Fprintf(os.Stdout, "%s\n", output) - } if err != nil { t.Fatal(err) } - output, err = load.RunLego( - "-m", "hubert@hubert.com", + err = load.RunLego( + "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", - "-d", "légô.wtf", + "-d", testDomain4, "--tls", "--tls.port", ":5001", "revoke") - - if len(output) > 0 { - fmt.Fprintf(os.Stdout, "%s\n", output) - } if err != nil { t.Fatal(err) } @@ -222,6 +205,7 @@ func TestChallengeTLS_Run_Revoke_Non_ASCII(t *testing.T) { func TestChallengeHTTP_Client_Obtain(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) + defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) @@ -239,17 +223,100 @@ func TestChallengeHTTP_Client_Obtain(t *testing.T) { reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) + user.registration = reg request := certificate.ObtainRequest{ - Domains: []string{"acme.wtf"}, + Domains: []string{testDomain1}, Bundle: true, } resource, err := client.Certificate.Obtain(request) require.NoError(t, err) require.NotNil(t, resource) - assert.Equal(t, "acme.wtf", resource.Domain) + assert.Equal(t, testDomain1, resource.Domain) + assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL) + assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL) + assert.NotEmpty(t, resource.Certificate) + assert.NotEmpty(t, resource.IssuerCertificate) + assert.Empty(t, resource.CSR) +} + +func TestChallengeHTTP_Client_Obtain_profile(t *testing.T) { + err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") + require.NoError(t, err) + + defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "Could not generate test key") + + user := &fakeUser{privateKey: privateKey} + config := lego.NewConfig(user) + config.CADirURL = load.PebbleOptions.HealthCheckURL + + client, err := lego.NewClient(config) + require.NoError(t, err) + + err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "5002")) + require.NoError(t, err) + + reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) + require.NoError(t, err) + + user.registration = reg + + request := certificate.ObtainRequest{ + Domains: []string{testDomain1}, + Bundle: true, + Profile: "shortlived", + } + resource, err := client.Certificate.Obtain(request) + require.NoError(t, err) + + require.NotNil(t, resource) + assert.Equal(t, testDomain1, resource.Domain) + assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL) + assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL) + assert.NotEmpty(t, resource.Certificate) + assert.NotEmpty(t, resource.IssuerCertificate) + assert.Empty(t, resource.CSR) +} + +func TestChallengeHTTP_Client_Obtain_emails_csr(t *testing.T) { + err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") + require.NoError(t, err) + + defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "Could not generate test key") + + user := &fakeUser{privateKey: privateKey} + config := lego.NewConfig(user) + config.CADirURL = load.PebbleOptions.HealthCheckURL + + client, err := lego.NewClient(config) + require.NoError(t, err) + + err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "5002")) + require.NoError(t, err) + + reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) + require.NoError(t, err) + + user.registration = reg + + request := certificate.ObtainRequest{ + Domains: []string{testDomain1}, + Bundle: true, + EmailAddresses: []string{testEmail1}, + } + resource, err := client.Certificate.Obtain(request) + require.NoError(t, err) + + require.NotNil(t, resource) + assert.Equal(t, testDomain1, resource.Domain) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL) assert.NotEmpty(t, resource.Certificate) @@ -260,6 +327,7 @@ func TestChallengeHTTP_Client_Obtain(t *testing.T) { func TestChallengeHTTP_Client_Obtain_notBefore_notAfter(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) + defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) @@ -277,12 +345,13 @@ func TestChallengeHTTP_Client_Obtain_notBefore_notAfter(t *testing.T) { reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) + user.registration = reg now := time.Now().UTC() request := certificate.ObtainRequest{ - Domains: []string{"acme.wtf"}, + Domains: []string{testDomain1}, NotBefore: now.Add(1 * time.Hour), NotAfter: now.Add(2 * time.Hour), Bundle: true, @@ -291,7 +360,7 @@ func TestChallengeHTTP_Client_Obtain_notBefore_notAfter(t *testing.T) { require.NoError(t, err) require.NotNil(t, resource) - assert.Equal(t, "acme.wtf", resource.Domain) + assert.Equal(t, testDomain1, resource.Domain) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL) assert.NotEmpty(t, resource.Certificate) @@ -307,6 +376,7 @@ func TestChallengeHTTP_Client_Obtain_notBefore_notAfter(t *testing.T) { func TestChallengeHTTP_Client_Registration_QueryRegistration(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) + defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) @@ -324,6 +394,7 @@ func TestChallengeHTTP_Client_Registration_QueryRegistration(t *testing.T) { reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) + user.registration = reg resource, err := client.Registration.QueryRegistration() @@ -339,6 +410,7 @@ func TestChallengeHTTP_Client_Registration_QueryRegistration(t *testing.T) { func TestChallengeTLS_Client_Obtain(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) + defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) @@ -356,6 +428,7 @@ func TestChallengeTLS_Client_Obtain(t *testing.T) { reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) + user.registration = reg // https://github.com/letsencrypt/pebble/issues/285 @@ -363,7 +436,7 @@ func TestChallengeTLS_Client_Obtain(t *testing.T) { require.NoError(t, err, "Could not generate test key") request := certificate.ObtainRequest{ - Domains: []string{"acme.wtf"}, + Domains: []string{testDomain1}, Bundle: true, PrivateKey: privateKeyCSR, } @@ -371,7 +444,7 @@ func TestChallengeTLS_Client_Obtain(t *testing.T) { require.NoError(t, err) require.NotNil(t, resource) - assert.Equal(t, "acme.wtf", resource.Domain) + assert.Equal(t, testDomain1, resource.Domain) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL) assert.NotEmpty(t, resource.Certificate) @@ -382,6 +455,7 @@ func TestChallengeTLS_Client_Obtain(t *testing.T) { func TestChallengeTLS_Client_ObtainForCSR(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) + defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) @@ -399,12 +473,10 @@ func TestChallengeTLS_Client_ObtainForCSR(t *testing.T) { reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) + user.registration = reg - csrRaw, err := os.ReadFile("./fixtures/csr.raw") - require.NoError(t, err) - - csr, err := x509.ParseCertificateRequest(csrRaw) + csr, err := x509.ParseCertificateRequest(createTestCSR(t)) require.NoError(t, err) resource, err := client.Certificate.ObtainForCSR(certificate.ObtainForCSRRequest{ @@ -414,7 +486,50 @@ func TestChallengeTLS_Client_ObtainForCSR(t *testing.T) { require.NoError(t, err) require.NotNil(t, resource) - assert.Equal(t, "acme.wtf", resource.Domain) + assert.Equal(t, testDomain1, resource.Domain) + assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL) + assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL) + assert.NotEmpty(t, resource.Certificate) + assert.NotEmpty(t, resource.IssuerCertificate) + assert.NotEmpty(t, resource.CSR) +} + +func TestChallengeTLS_Client_ObtainForCSR_profile(t *testing.T) { + err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") + require.NoError(t, err) + + defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "Could not generate test key") + + user := &fakeUser{privateKey: privateKey} + config := lego.NewConfig(user) + config.CADirURL = load.PebbleOptions.HealthCheckURL + + client, err := lego.NewClient(config) + require.NoError(t, err) + + err = client.Challenge.SetTLSALPN01Provider(tlsalpn01.NewProviderServer("", "5001")) + require.NoError(t, err) + + reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) + require.NoError(t, err) + + user.registration = reg + + csr, err := x509.ParseCertificateRequest(createTestCSR(t)) + require.NoError(t, err) + + resource, err := client.Certificate.ObtainForCSR(certificate.ObtainForCSRRequest{ + CSR: csr, + Bundle: true, + Profile: "shortlived", + }) + require.NoError(t, err) + + require.NotNil(t, resource) + assert.Equal(t, testDomain1, resource.Domain) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL) assert.NotEmpty(t, resource.Certificate) @@ -425,6 +540,7 @@ func TestChallengeTLS_Client_ObtainForCSR(t *testing.T) { func TestRegistrar_UpdateAccount(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) + defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) @@ -432,7 +548,7 @@ func TestRegistrar_UpdateAccount(t *testing.T) { user := &fakeUser{ privateKey: privateKey, - email: "foo@example.com", + email: testEmail1, } config := lego.NewConfig(user) config.CADirURL = load.PebbleOptions.HealthCheckURL @@ -443,13 +559,13 @@ func TestRegistrar_UpdateAccount(t *testing.T) { regOptions := registration.RegisterOptions{TermsOfServiceAgreed: true} reg, err := client.Registration.Register(regOptions) require.NoError(t, err) - require.Equal(t, []string{"mailto:foo@example.com"}, reg.Body.Contact) + require.Equal(t, []string{"mailto:" + testEmail1}, reg.Body.Contact) user.registration = reg - user.email = "bar@example.com" + user.email = testEmail2 resource, err := client.Registration.UpdateRegistration(regOptions) require.NoError(t, err) - require.Equal(t, []string{"mailto:bar@example.com"}, resource.Body.Contact) + require.Equal(t, []string{"mailto:" + testEmail2}, resource.Body.Contact) require.Equal(t, reg.URI, resource.URI) } @@ -462,3 +578,53 @@ type fakeUser struct { func (f *fakeUser) GetEmail() string { return f.email } func (f *fakeUser) GetRegistration() *registration.Resource { return f.registration } func (f *fakeUser) GetPrivateKey() crypto.PrivateKey { return f.privateKey } + +func createTestCSRFile(t *testing.T, raw bool) string { + t.Helper() + + csr := createTestCSR(t) + + if raw { + filename := filepath.Join(t.TempDir(), "csr.raw") + + fileRaw, err := os.Create(filename) + require.NoError(t, err) + + defer fileRaw.Close() + + _, err = fileRaw.Write(csr) + require.NoError(t, err) + + return filename + } + + filename := filepath.Join(t.TempDir(), "csr.cert") + + file, err := os.Create(filename) + require.NoError(t, err) + + defer file.Close() + + _, err = file.Write(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csr})) + require.NoError(t, err) + + return filename +} + +func createTestCSR(t *testing.T) []byte { + t.Helper() + + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(t, err) + + csr, err := certcrypto.CreateCSR(privateKey, certcrypto.CSROptions{ + Domain: testDomain1, + SAN: []string{ + testDomain1, + testDomain2, + }, + }) + require.NoError(t, err) + + return csr +} diff --git a/e2e/dnschallenge/dns_challenges_test.go b/e2e/dnschallenge/dns_challenges_test.go index 605a77bd0..9dd9ab0d6 100644 --- a/e2e/dnschallenge/dns_challenges_test.go +++ b/e2e/dnschallenge/dns_challenges_test.go @@ -18,6 +18,11 @@ import ( "github.com/stretchr/testify/require" ) +const ( + testDomain1 = "légo.localhost" + testDomain2 = "*.légo.localhost" +) + var load = loader.EnvLoader{ PebbleOptions: &loader.CmdOption{ HealthCheckURL: "https://localhost:15000/dir", @@ -28,6 +33,7 @@ var load = loader.EnvLoader{ LegoOptions: []string{ "LEGO_CA_CERTIFICATES=../fixtures/certs/pebble.minica.pem", "EXEC_PATH=../fixtures/update-dns.sh", + "LEGO_DEBUG_ACME_HTTP_CLIENT=1", }, ChallSrv: &loader.CmdOption{ Args: []string{"-http01", ":5012", "-tlsalpn01", ":5011"}, @@ -39,7 +45,7 @@ func TestMain(m *testing.M) { } func TestDNSHelp(t *testing.T) { - output, err := load.RunLego("dnshelp") + output, err := load.RunLegoCombinedOutput("dnshelp") if err != nil { fmt.Fprintf(os.Stderr, "%s\n", output) t.Fatal(err) @@ -51,20 +57,15 @@ func TestDNSHelp(t *testing.T) { func TestChallengeDNS_Run(t *testing.T) { loader.CleanLegoFiles() - output, err := load.RunLego( - "-m", "hubert@hubert.com", + err := load.RunLego( "--accept-tos", "--dns", "exec", "--dns.resolvers", ":8053", "--dns.disable-cp", "-s", "https://localhost:15000/dir", - "-d", "*.légo.acme", - "-d", "légo.acme", + "-d", testDomain2, + "-d", testDomain1, "run") - - if len(output) > 0 { - fmt.Fprintf(os.Stdout, "%s\n", output) - } if err != nil { t.Fatal(err) } @@ -73,10 +74,12 @@ func TestChallengeDNS_Run(t *testing.T) { func TestChallengeDNS_Client_Obtain(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "../fixtures/certs/pebble.minica.pem") require.NoError(t, err) + defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() err = os.Setenv("EXEC_PATH", "../fixtures/update-dns.sh") require.NoError(t, err) + defer func() { _ = os.Unsetenv("EXEC_PATH") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) @@ -99,9 +102,10 @@ func TestChallengeDNS_Client_Obtain(t *testing.T) { reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) + user.registration = reg - domains := []string{"*.légo.acme", "légo.acme"} + domains := []string{testDomain2, testDomain1} // https://github.com/letsencrypt/pebble/issues/285 privateKeyCSR, err := rsa.GenerateKey(rand.Reader, 2048) @@ -116,7 +120,65 @@ func TestChallengeDNS_Client_Obtain(t *testing.T) { require.NoError(t, err) require.NotNil(t, resource) - assert.Equal(t, "*.xn--lgo-bma.acme", resource.Domain) + assert.Equal(t, "*.xn--lgo-bma.localhost", resource.Domain) + assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertURL) + assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertStableURL) + assert.NotEmpty(t, resource.Certificate) + assert.NotEmpty(t, resource.IssuerCertificate) + assert.Empty(t, resource.CSR) +} + +func TestChallengeDNS_Client_Obtain_profile(t *testing.T) { + err := os.Setenv("LEGO_CA_CERTIFICATES", "../fixtures/certs/pebble.minica.pem") + require.NoError(t, err) + + defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() + + err = os.Setenv("EXEC_PATH", "../fixtures/update-dns.sh") + require.NoError(t, err) + + defer func() { _ = os.Unsetenv("EXEC_PATH") }() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "Could not generate test key") + + user := &fakeUser{privateKey: privateKey} + config := lego.NewConfig(user) + config.CADirURL = "https://localhost:15000/dir" + + client, err := lego.NewClient(config) + require.NoError(t, err) + + provider, err := dns.NewDNSChallengeProviderByName("exec") + require.NoError(t, err) + + err = client.Challenge.SetDNS01Provider(provider, + dns01.AddRecursiveNameservers([]string{":8053"}), + dns01.DisableAuthoritativeNssPropagationRequirement()) + require.NoError(t, err) + + reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) + require.NoError(t, err) + + user.registration = reg + + domains := []string{testDomain2, testDomain1} + + // https://github.com/letsencrypt/pebble/issues/285 + privateKeyCSR, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "Could not generate test key") + + request := certificate.ObtainRequest{ + Domains: domains, + Bundle: true, + PrivateKey: privateKeyCSR, + Profile: "shortlived", + } + resource, err := client.Certificate.Obtain(request) + require.NoError(t, err) + + require.NotNil(t, resource) + assert.Equal(t, "*.xn--lgo-bma.localhost", resource.Domain) assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertURL) assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertStableURL) assert.NotEmpty(t, resource.Certificate) diff --git a/e2e/fixtures/certs/localhost/cert.pem b/e2e/fixtures/certs/localhost/cert.pem index 2866a2b48..d81d29e70 100644 --- a/e2e/fixtures/certs/localhost/cert.pem +++ b/e2e/fixtures/certs/localhost/cert.pem @@ -1,19 +1,20 @@ -----BEGIN CERTIFICATE----- -MIIDGzCCAgOgAwIBAgIIbEfayDFsBtwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE -AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMDcx -MjA2MTk0MjEwWjAUMRIwEAYDVQQDEwlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB -AQUAA4IBDwAwggEKAoIBAQCbFMW3DXXdErvQf2lCZ0qz0DGEWadDoF0O2neM5mVa -VQ7QGW0xc5Qwvn3Tl62C0JtwLpF0pG2BICIN+DHdVaIUwkf77iBS2doH1I3waE1I -8GkV9JrYmFY+j0dA1SwBmqUZNXhLNwZGq1a91nFSI59DZNy/JciqxoPX2K++ojU2 -FPpuXe2t51NmXMsszpa+TDqF/IeskA9A/ws6UIh4Mzhghx7oay2/qqj2IIPjAmJj -i73kdUvtEry3wmlkBvtVH50+FscS9WmPC5h3lDTk5nbzSAXKuFusotuqy3XTgY5B -PiRAwkZbEY43JNfqenQPHo7mNTt29i+NVVrBsnAa5ovrAgMBAAGjYzBhMA4GA1Ud -DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0T -AQH/BAIwADAiBgNVHREEGzAZgglsb2NhbGhvc3SCBnBlYmJsZYcEfwAAATANBgkq -hkiG9w0BAQsFAAOCAQEAYIkXff8H28KS0KyLHtbbSOGU4sujHHVwiVXSATACsNAE -D0Qa8hdtTQ6AUqA6/n8/u1tk0O4rPE/cTpsM3IJFX9S3rZMRsguBP7BSr1Lq/XAB -7JP/CNHt+Z9aKCKcg11wIX9/B9F7pyKM3TdKgOpqXGV6TMuLjg5PlYWI/07lVGFW -/mSJDRs8bSCFmbRtEqc4lpwlrpz+kTTnX6G7JDLfLWYw/xXVqwFfdengcDTHCc8K -wtgGq/Gu6vcoBxIO3jaca+OIkMfxxXmGrcNdseuUCa3RMZ8Qy03DqGu6Y6XQyK4B -W8zIG6H9SVKkAznM2yfYhW8v2ktcaZ95/OBHY97ZIw== +MIIDMDCCAhigAwIBAgIILDt8c2fMw2IwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE +AxMVbWluaWNhIHJvb3QgY2EgNTM0NWU2MB4XDTI1MDkwMzIzNDAwNVoXDTI3MTAw +MzIzNDAwNVowFDESMBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAmxTFtw113RK70H9pQmdKs9AxhFmnQ6BdDtp3jOZlWlUO +0BltMXOUML5905etgtCbcC6RdKRtgSAiDfgx3VWiFMJH++4gUtnaB9SN8GhNSPBp +FfSa2JhWPo9HQNUsAZqlGTV4SzcGRqtWvdZxUiOfQ2TcvyXIqsaD19ivvqI1NhT6 +bl3tredTZlzLLM6Wvkw6hfyHrJAPQP8LOlCIeDM4YIce6Gstv6qo9iCD4wJiY4u9 +5HVL7RK8t8JpZAb7VR+dPhbHEvVpjwuYd5Q05OZ280gFyrhbrKLbqst104GOQT4k +QMJGWxGONyTX6np0Dx6O5jU7dvYvjVVawbJwGuaL6wIDAQABo3oweDAOBgNVHQ8B +Af8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADAfBgNV +HSMEGDAWgBSu8RGpErgYUoYnQuwCq+/ggTiEjDAiBgNVHREEGzAZgglsb2NhbGhv +c3SCBnBlYmJsZYcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAAB0gkekXCNOwqWmY +vQ2lLJ8Zk2WzQ9B+VOC27IgxEEuskZyCpyXAbJB9sCGQWZhAARyaI4SPRGGagcug +d1SwDWdPGeSJzF3aDnXDYoP9Zw2KqiqVZTngeoiw8Yn0F8PNriANwRLybouX7mMc +4V7T5+2k4SUs7pFH4KO0a0XBCcjXDjdKuBljftRTXCHzJzfRtmieCCuZlpnp5sHx +hKa/uxKGyyZB+4Y3MrzsiQSCBOr9G4TH9RofmNcawl+tsVe08zLV/XVhrbakKEs7 +Y7MGHSj3BkPFF32NObc0znqWzTaUD9hU+rXWGANM4sXd4dagdnxfrb7i0WYhcUFj +9Try8Q== -----END CERTIFICATE----- diff --git a/e2e/fixtures/certs/pebble.minica.pem b/e2e/fixtures/certs/pebble.minica.pem index a69a4c419..5578b5b55 100644 --- a/e2e/fixtures/certs/pebble.minica.pem +++ b/e2e/fixtures/certs/pebble.minica.pem @@ -1,19 +1,20 @@ -----BEGIN CERTIFICATE----- -MIIDCTCCAfGgAwIBAgIIJOLbes8sTr4wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE -AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMTcx -MjA2MTk0MjEwWjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAyNGUyZGIwggEi +MIIDPzCCAiegAwIBAgIIU0Xm9UFdQxUwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE +AxMVbWluaWNhIHJvb3QgY2EgNTM0NWU2MCAXDTI1MDkwMzIzNDAwNVoYDzIxMjUw +OTAzMjM0MDA1WjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSA1MzQ1ZTYwggEi MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5WgZNoVJandj43kkLyU50vzCZ alozvdRo3OFiKoDtmqKPNWRNO2hC9AUNxTDJco51Yc42u/WV3fPbbhSznTiOOVtn Ajm6iq4I5nZYltGGZetGDOQWr78y2gWY+SG078MuOO2hyDIiKtVc3xiXYA+8Hluu 9F8KbqSS1h55yxZ9b87eKR+B0zu2ahzBCIHKmKWgc6N13l7aDxxY3D6uq8gtJRU0 toumyLbdzGcupVvjbjDP11nl07RESDWBLG1/g3ktJvqIa4BWgU2HMh4rND6y8OD3 Hy3H8MY6CElL+MOCbFJjWqhtOxeFyZZV9q3kYnk9CAuQJKMEGuN4GU6tzhW1AgMB -AAGjRTBDMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB -BQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkqhkiG9w0BAQsFAAOCAQEAF85v -d40HK1ouDAtWeO1PbnWfGEmC5Xa478s9ddOd9Clvp2McYzNlAFfM7kdcj6xeiNhF -WPIfaGAi/QdURSL/6C1KsVDqlFBlTs9zYfh2g0UXGvJtj1maeih7zxFLvet+fqll -xseM4P9EVJaQxwuK/F78YBt0tCNfivC6JNZMgxKF59h0FBpH70ytUSHXdz7FKwix -Mfn3qEb9BXSk0Q3prNV5sOV3vgjEtB4THfDxSz9z3+DepVnW3vbbqwEbkXdk3j82 -2muVldgOUgTwK8eT+XdofVdntzU/kzygSAtAQwLJfn51fS1GvEcYGBc1bDryIqmF -p9BI7gVKtWSZYegicA== +AAGjezB5MA4GA1UdDwEB/wQEAwIChDATBgNVHSUEDDAKBggrBgEFBQcDATASBgNV +HRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSu8RGpErgYUoYnQuwCq+/ggTiEjDAf +BgNVHSMEGDAWgBSu8RGpErgYUoYnQuwCq+/ggTiEjDANBgkqhkiG9w0BAQsFAAOC +AQEAXDVYov1+f6EL7S41LhYQkEX/GyNNzsEvqxE9U0+3Iri5JfkcNOiA9O9L6Z+Y +bqcsXV93s3vi4r4WSWuc//wHyJYrVe5+tK4nlFpbJOvfBUtnoBDyKNxXzZCxFJVh +f9uc8UejRfQMFbDbhWY/x83y9BDufJHHq32OjCIN7gp2UR8rnfYvlz7Zg4qkJBsn +DG4dwd+pRTCFWJOVIG0JoNhK3ZmE7oJ1N4H38XkZ31NPcMksKxpsLLIS9+mosZtg +4olL7tMPJklx5ZaeMFaKRDq4Gdxkbw4+O4vRgNm3Z8AXWKknOdfgdpqLUPPhRcP4 +v1lhy71EhBuXXwRQJry0lTdF+w== -----END CERTIFICATE----- diff --git a/e2e/fixtures/csr.cert b/e2e/fixtures/csr.cert deleted file mode 100644 index cece7ddec..000000000 --- a/e2e/fixtures/csr.cert +++ /dev/null @@ -1,16 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIICfjCCAWYCAQAwEzERMA8GA1UEAxMIYWNtZS53dGYwggEiMA0GCSqGSIb3DQEB -AQUAA4IBDwAwggEKAoIBAQDAhXnho1w9OPHWs4YSMahYbG4Ui1K6hsHytBZfhsz0 -09igSWzHMEFZYHZJVuSr60enuJSZRhgwDjfhQWSUgHgKItLPnlNVYM6RhVaW0WfT -w6CpmE2AuH3WuQbrR2he1Nt0xfUJla+VWOFZuW7GhgBiV5iWBvdLv6Ztgh8eATjo -2vG2R+KuSUzrm6h+sb3nUR28OYunZ3vESjNwnL3/D/1th2rFpe3EA3em1HArJdXN -F4eclciun5Js17AS9tdoHEEZMMBWyViiuz3CQlh+YD2qAvqaubanWNa+r+iijMvd -4HlDHC99LTk6TJoSKoL+E/OGKmntLqmBJ1UrCFgvnw3DAgMBAAGgJjAkBgkqhkiG -9w0BCQ4xFzAVMBMGA1UdEQQMMAqCCGFjbWUud3RmMA0GCSqGSIb3DQEBCwUAA4IB -AQAfBLR8njftxf15V49szNsgNaG7Y5UQFwgl8pyiIaanGvX1DE0BtU1RB/w7itzX -wW5W/wjielEbs1XkI2uz3hkebvHVA1QpA7bbrX01WonS18xCkiRDj8ZqFEG4vEGa -HswzGUfq2v0gCOIPpVGE+8Q2Y7In5zwEfev+5DkHox4/vgwMhyPMI+y7jKtdG/dV -U58SFnt/F1raoSmR6vfDcAFXm/L8LXEkxqqefFbhiRHRqQar1Wr15BH//swmNzEW -5SVCCHcyIqreSua8uPjBcJ8aYVLniX6DMRyYv4ij/PSvSQy9xJDewLqR235WfTd/ -tk4hhJaqizKDpsvB+UFod5o5 ------END CERTIFICATE REQUEST----- diff --git a/e2e/fixtures/csr.raw b/e2e/fixtures/csr.raw deleted file mode 100644 index f4bb701cd..000000000 Binary files a/e2e/fixtures/csr.raw and /dev/null differ diff --git a/e2e/fixtures/pebble-config-dns.json b/e2e/fixtures/pebble-config-dns.json index 4834825a4..dd5b63142 100644 --- a/e2e/fixtures/pebble-config-dns.json +++ b/e2e/fixtures/pebble-config-dns.json @@ -4,6 +4,16 @@ "certificate": "fixtures/certs/localhost/cert.pem", "privateKey": "fixtures/certs/localhost/key.pem", "httpPort": 5004, - "tlsPort": 5003 + "tlsPort": 5003, + "profiles": { + "default": { + "description": "The profile you know and love", + "validityPeriod": 7776000 + }, + "shortlived": { + "description": "A short-lived cert profile, without actual enforcement", + "validityPeriod": 518400 + } + } } } diff --git a/e2e/fixtures/pebble-config.json b/e2e/fixtures/pebble-config.json index f2abe6ab8..dcf659b4c 100644 --- a/e2e/fixtures/pebble-config.json +++ b/e2e/fixtures/pebble-config.json @@ -4,6 +4,16 @@ "certificate": "fixtures/certs/localhost/cert.pem", "privateKey": "fixtures/certs/localhost/key.pem", "httpPort": 5002, - "tlsPort": 5001 + "tlsPort": 5001, + "profiles": { + "default": { + "description": "The profile you know and love", + "validityPeriod": 7776000 + }, + "shortlived": { + "description": "A short-lived cert profile, without actual enforcement", + "validityPeriod": 518400 + } + } } } diff --git a/e2e/loader/loader.go b/e2e/loader/loader.go index 7e8ff539f..3e63302a3 100644 --- a/e2e/loader/loader.go +++ b/e2e/loader/loader.go @@ -1,7 +1,9 @@ package loader import ( + "bufio" "bytes" + "context" "crypto/tls" "errors" "fmt" @@ -15,6 +17,7 @@ import ( "time" "github.com/go-acme/lego/v4/platform/wait" + "github.com/ldez/grignotin/goenv" ) const ( @@ -40,12 +43,14 @@ func (l *EnvLoader) MainTest(m *testing.M) int { if _, e2e := os.LookupEnv("LEGO_E2E_TESTS"); !e2e { fmt.Fprintln(os.Stderr, "skipping test: e2e tests are disabled. (no 'LEGO_E2E_TESTS' env var)") fmt.Println("PASS") + return 0 } if _, err := exec.LookPath("git"); err != nil { fmt.Fprintln(os.Stderr, "skipping because git command not found") fmt.Println("PASS") + return 0 } @@ -53,6 +58,7 @@ func (l *EnvLoader) MainTest(m *testing.M) int { if _, err := exec.LookPath(cmdNamePebble); err != nil { fmt.Fprintln(os.Stderr, "skipping because pebble binary not found") fmt.Println("PASS") + return 0 } } @@ -61,6 +67,7 @@ func (l *EnvLoader) MainTest(m *testing.M) int { if _, err := exec.LookPath(cmdNameChallSrv); err != nil { fmt.Fprintln(os.Stderr, "skipping because challtestsrv binary not found") fmt.Println("PASS") + return 0 } } @@ -73,6 +80,7 @@ func (l *EnvLoader) MainTest(m *testing.M) int { legoBinary, tearDown, err := buildLego() defer tearDown() + if err != nil { fmt.Fprintln(os.Stderr, err) return 1 @@ -87,7 +95,7 @@ func (l *EnvLoader) MainTest(m *testing.M) int { return m.Run() } -func (l *EnvLoader) RunLego(arg ...string) ([]byte, error) { +func (l *EnvLoader) RunLegoCombinedOutput(arg ...string) ([]byte, error) { cmd := exec.Command(l.lego, arg...) cmd.Env = l.LegoOptions @@ -96,12 +104,44 @@ func (l *EnvLoader) RunLego(arg ...string) ([]byte, error) { return cmd.CombinedOutput() } +func (l *EnvLoader) RunLego(arg ...string) error { + cmd := exec.Command(l.lego, arg...) + cmd.Env = l.LegoOptions + + fmt.Printf("$ %s\n", strings.Join(cmd.Args, " ")) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("create pipe: %w", err) + } + + cmd.Stderr = cmd.Stdout + + err = cmd.Start() + if err != nil { + return fmt.Errorf("start command: %w", err) + } + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + println(scanner.Text()) + } + + err = cmd.Wait() + if err != nil { + return fmt.Errorf("wait command: %w", err) + } + + return nil +} + func (l *EnvLoader) launchPebble() func() { if l.PebbleOptions == nil { return func() {} } pebble, outPebble := l.cmdPebble() + go func() { err := pebble.Run() if err != nil { @@ -114,6 +154,7 @@ func (l *EnvLoader) launchPebble() func() { if err != nil { fmt.Println(err) } + fmt.Println(outPebble.String()) } } @@ -126,11 +167,13 @@ func (l *EnvLoader) cmdPebble() (*exec.Cmd, *bytes.Buffer) { if err != nil { panic(err) } + cmd.Dir = dir fmt.Printf("$ %s\n", strings.Join(cmd.Args, " ")) var b bytes.Buffer + cmd.Stdout = &b cmd.Stderr = &b @@ -139,6 +182,7 @@ func (l *EnvLoader) cmdPebble() (*exec.Cmd, *bytes.Buffer) { func pebbleHealthCheck(options *CmdOption) { client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} + err := wait.For("pebble", 10*time.Second, 500*time.Millisecond, func() (bool, error) { resp, err := client.Get(options.HealthCheckURL) if err != nil { @@ -162,6 +206,7 @@ func (l *EnvLoader) launchChallSrv() func() { } challtestsrv, outChalSrv := l.cmdChallSrv() + go func() { err := challtestsrv.Run() if err != nil { @@ -174,6 +219,7 @@ func (l *EnvLoader) launchChallSrv() func() { if err != nil { fmt.Println(err) } + fmt.Println(outChalSrv.String()) } } @@ -184,6 +230,7 @@ func (l *EnvLoader) cmdChallSrv() (*exec.Cmd, *bytes.Buffer) { fmt.Printf("$ %s\n", strings.Join(cmd.Args, " ")) var b bytes.Buffer + cmd.Stdout = &b cmd.Stderr = &b @@ -195,6 +242,7 @@ func buildLego() (string, func(), error) { if err != nil { return "", func() {}, err } + defer func() { _ = os.Chdir(here) }() buildPath, err := os.MkdirTemp("", "lego_test") @@ -228,6 +276,7 @@ func buildLego() (string, func(), error) { return binary, func() { _ = os.RemoveAll(buildPath) + CleanLegoFiles() }, nil } @@ -249,6 +298,7 @@ func build(binary string) error { if err != nil { return err } + cmd := exec.Command(toolPath, "build", "-o", binary) output, err := cmd.CombinedOutput() @@ -279,8 +329,13 @@ func goTool() (string, error) { exeSuffix = ".exe" } - path := filepath.Join(runtime.GOROOT(), "bin", "go"+exeSuffix) - if _, err := os.Stat(path); err == nil { + goRoot, err := goenv.GetOne(context.Background(), goenv.GOROOT) + if err != nil { + return "", fmt.Errorf("cannot find go root: %w", err) + } + + path := filepath.Join(goRoot, "bin", "go"+exeSuffix) + if _, err = os.Stat(path); err == nil { return path, nil } @@ -295,6 +350,7 @@ func goTool() (string, error) { func CleanLegoFiles() { cmd := exec.Command("rm", "-rf", ".lego") fmt.Printf("$ %s\n", strings.Join(cmd.Args, " ")) + output, err := cmd.CombinedOutput() if err != nil { fmt.Println(string(output)) diff --git a/e2e/readme.md b/e2e/readme.md index 746b9d726..171170507 100644 --- a/e2e/readme.md +++ b/e2e/readme.md @@ -1,20 +1,9 @@ # E2E tests -How to run: - -- Add the following entries to your `/etc/hosts`: -``` -127.0.0.1 acme.wtf -127.0.0.1 lego.wtf -127.0.0.1 acme.lego.wtf -127.0.0.1 légô.wtf -127.0.0.1 xn--lg-bja9b.wtf -``` - - Install [Pebble](https://github.com/letsencrypt/pebble): ```bash -go install github.com/letsencrypt/pebble/v2/cmd/pebble@main -go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@main +go install github.com/letsencrypt/pebble/v2/cmd/pebble@v2.9.0 +go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@v2.9.0 ``` - Launch tests: diff --git a/go.mod b/go.mod index ed000aac2..b8e88428e 100644 --- a/go.mod +++ b/go.mod @@ -1,210 +1,229 @@ module github.com/go-acme/lego/v4 -go 1.22.0 +go 1.24.0 require ( - cloud.google.com/go/compute/metadata v0.5.2 + cloud.google.com/go/compute/metadata v0.9.0 github.com/Azure/azure-sdk-for-go v68.0.0+incompatible - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 - github.com/Azure/go-autorest/autorest v0.11.29 + github.com/Azure/go-autorest/autorest v0.11.30 github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 - github.com/Azure/go-autorest/autorest/to v0.4.0 - github.com/BurntSushi/toml v1.4.0 - github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 - github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 - github.com/aliyun/alibaba-cloud-sdk-go v1.63.47 - github.com/aws/aws-sdk-go-v2 v1.32.3 - github.com/aws/aws-sdk-go-v2/config v1.28.1 - github.com/aws/aws-sdk-go-v2/credentials v1.17.42 - github.com/aws/aws-sdk-go-v2/service/lightsail v1.42.3 - github.com/aws/aws-sdk-go-v2/service/route53 v1.46.0 - github.com/aws/aws-sdk-go-v2/service/s3 v1.66.2 - github.com/aws/aws-sdk-go-v2/service/sts v1.32.3 - github.com/cenkalti/backoff/v4 v4.3.0 - github.com/civo/civogo v0.3.11 - github.com/cloudflare/cloudflare-go v0.108.0 - github.com/cpu/goacmedns v0.1.1 - github.com/dnsimple/dnsimple-go v1.7.0 - github.com/exoscale/egoscale/v3 v3.1.7 - github.com/go-jose/go-jose/v4 v4.0.4 - github.com/go-viper/mapstructure/v2 v2.2.1 - github.com/google/go-querystring v1.1.0 + github.com/Azure/go-autorest/autorest/to v0.4.1 + github.com/BurntSushi/toml v1.6.0 + github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 + github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15 + github.com/alibabacloud-go/tea v1.4.0 + github.com/aliyun/credentials-go v1.4.7 + github.com/aws/aws-sdk-go-v2 v1.41.1 + github.com/aws/aws-sdk-go-v2/config v1.32.8 + github.com/aws/aws-sdk-go-v2/credentials v1.19.8 + github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11 + github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 + github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 + github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 + github.com/aziontech/azionapi-go-sdk v0.144.0 + github.com/baidubce/bce-sdk-go v0.9.260 + github.com/cenkalti/backoff/v5 v5.0.3 + github.com/dnsimple/dnsimple-go/v4 v4.0.0 + github.com/exoscale/egoscale/v3 v3.1.33 + github.com/go-acme/alidns-20150109/v4 v4.7.0 + github.com/go-acme/esa-20240910/v2 v2.48.0 + github.com/go-acme/jdcloud-sdk-go v1.64.0 + github.com/go-acme/tencentclouddnspod v1.3.24 + github.com/go-acme/tencentedgdeone v1.3.38 + github.com/go-jose/go-jose/v4 v4.1.3 + github.com/go-viper/mapstructure/v2 v2.5.0 + github.com/google/go-cmp v0.7.0 + github.com/google/go-querystring v1.2.0 + github.com/google/uuid v1.6.0 github.com/gophercloud/gophercloud v1.14.1 github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 - github.com/hashicorp/go-retryablehttp v0.7.7 - github.com/hashicorp/go-version v1.7.0 - github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.120 + github.com/hashicorp/go-retryablehttp v0.7.8 + github.com/hashicorp/go-version v1.8.0 + github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df - github.com/infobloxopen/infoblox-go-client v1.1.1 + github.com/infobloxopen/infoblox-go-client/v2 v2.10.0 github.com/labbsr0x/bindman-dns-webhook v1.0.2 - github.com/linode/linodego v1.42.0 + github.com/ldez/grignotin v0.10.1 + github.com/linode/linodego v1.65.0 github.com/liquidweb/liquidweb-go v1.6.4 github.com/mattn/go-isatty v0.0.20 - github.com/miekg/dns v1.1.62 + github.com/miekg/dns v1.1.72 github.com/mimuret/golang-iij-dpf v0.9.1 - github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 - github.com/nrdcg/auroradns v1.1.0 - github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3 - github.com/nrdcg/desec v0.8.0 + github.com/namedotcom/go/v4 v4.0.2 + github.com/nrdcg/auroradns v1.2.0 + github.com/nrdcg/bunny-go v0.1.0 + github.com/nrdcg/desec v0.11.1 github.com/nrdcg/dnspod-go v0.4.0 - github.com/nrdcg/freemyip v0.2.0 - github.com/nrdcg/goinwx v0.10.0 - github.com/nrdcg/mailinabox v0.2.0 - github.com/nrdcg/namesilo v0.2.1 + github.com/nrdcg/freemyip v0.3.0 + github.com/nrdcg/goacmedns v0.2.0 + github.com/nrdcg/goinwx v0.12.0 + github.com/nrdcg/mailinabox v0.3.0 + github.com/nrdcg/namesilo v0.5.0 github.com/nrdcg/nodion v0.1.0 + github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2 + github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2 github.com/nrdcg/porkbun v0.4.0 + github.com/nrdcg/vegadns v0.3.0 github.com/nzdjb/go-metaname v1.0.0 - github.com/oracle/oci-go-sdk/v65 v65.77.1 - github.com/ovh/go-ovh v1.6.0 - github.com/pquerna/otp v1.4.0 + github.com/ovh/go-ovh v1.9.0 + github.com/pquerna/otp v1.5.0 github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 github.com/regfish/regfish-dnsapi-go v0.1.1 - github.com/sacloud/api-client-go v0.2.10 - github.com/sacloud/iaas-api-go v1.12.0 - github.com/scaleway/scaleway-sdk-go v1.0.0-beta.30 + github.com/sacloud/api-client-go v0.3.3 + github.com/sacloud/iaas-api-go v1.23.1 + github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 github.com/selectel/domains-go v1.1.0 - github.com/selectel/go-selvpcclient/v3 v3.1.1 - github.com/softlayer/softlayer-go v1.1.7 - github.com/stretchr/testify v1.9.0 - github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1034 - github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1034 - github.com/transip/gotransip/v6 v6.26.0 - github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec - github.com/urfave/cli/v2 v2.27.5 - github.com/vinyldns/go-vinyldns v0.9.16 - github.com/volcengine/volc-sdk-golang v1.0.183 - github.com/vultr/govultr/v3 v3.9.1 - github.com/yandex-cloud/go-genproto v0.0.0-20241101135610-76a0cfc1a773 - github.com/yandex-cloud/go-sdk v0.0.0-20241101143304-947cf519f6bd - golang.org/x/crypto v0.28.0 - golang.org/x/net v0.30.0 - golang.org/x/oauth2 v0.23.0 - golang.org/x/time v0.7.0 - google.golang.org/api v0.204.0 - gopkg.in/ns1/ns1-go.v2 v2.12.2 + github.com/selectel/go-selvpcclient/v4 v4.1.0 + github.com/softlayer/softlayer-go v1.2.1 + github.com/stretchr/testify v1.11.1 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48 + github.com/transip/gotransip/v6 v6.26.1 + github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419 + github.com/urfave/cli/v2 v2.27.7 + github.com/vinyldns/go-vinyldns v0.9.17 + github.com/volcengine/volc-sdk-golang v1.0.237 + github.com/vultr/govultr/v3 v3.27.0 + github.com/yandex-cloud/go-genproto v0.54.0 + github.com/yandex-cloud/go-sdk/services/dns v0.0.36 + github.com/yandex-cloud/go-sdk/v2 v2.56.0 + golang.org/x/crypto v0.48.0 + golang.org/x/net v0.50.0 + golang.org/x/oauth2 v0.35.0 + golang.org/x/text v0.34.0 + golang.org/x/time v0.14.0 + google.golang.org/api v0.267.0 + gopkg.in/ns1/ns1-go.v2 v2.17.2 gopkg.in/yaml.v2 v2.4.0 - software.sslmate.com/src/go-pkcs12 v0.5.0 + software.sslmate.com/src/go-pkcs12 v0.7.0 ) require ( - cloud.google.com/go/auth v0.10.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.5 // indirect + cloud.google.com/go/auth v0.18.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest/adal v0.9.22 // indirect github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect - github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.18 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.22 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.22 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.22 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.3 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.24.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.3 // indirect - github.com/aws/smithy-go v1.22.0 // indirect - github.com/benbjohnson/clock v1.3.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect + github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect + github.com/alibabacloud-go/debug v1.0.1 // indirect + github.com/alibabacloud-go/openapi-util v0.1.1 // indirect + github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect + github.com/aws/smithy-go v1.24.0 // indirect + github.com/benbjohnson/clock v1.3.5 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dimchansky/utfbom v1.1.1 // indirect + github.com/fatih/color v1.16.0 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/ghodss/yaml v1.0.0 // indirect github.com/go-errors/errors v1.0.1 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.16.0 // indirect - github.com/go-resty/resty/v2 v2.13.1 // indirect - github.com/goccy/go-json v0.10.3 // indirect - github.com/gofrs/flock v0.12.1 // indirect - github.com/golang-jwt/jwt/v4 v4.5.1 // indirect - github.com/golang-jwt/jwt/v5 v5.2.1 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/google/s2a-go v0.1.8 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect - github.com/googleapis/gax-go/v2 v2.13.0 // indirect - github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/go-playground/validator/v10 v10.23.0 // indirect + github.com/go-resty/resty/v2 v2.17.1 // indirect + github.com/goccy/go-yaml v1.9.8 // indirect + github.com/gofrs/flock v0.13.0 // indirect + github.com/gofrs/uuid v4.4.0+incompatible // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect + github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/labbsr0x/goh v1.0.1 // indirect - github.com/leodido/go-urn v1.2.4 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/liquidweb/liquidweb-cli v0.6.9 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect - github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/peterhellberg/link v1.2.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/sacloud/go-http v0.1.8 // indirect - github.com/sacloud/packages-go v0.0.10 // indirect + github.com/sacloud/go-http v0.1.9 // indirect + github.com/sacloud/packages-go v0.0.12 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/shopspring/decimal v1.3.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/smartystreets/assertions v1.0.1 // indirect - github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect - github.com/sony/gobreaker v0.5.0 // indirect + github.com/sony/gobreaker v1.0.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/cast v1.7.0 // indirect + github.com/spf13/pflag v1.0.7 // indirect github.com/spf13/viper v1.18.2 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect - go.mongodb.org/mongo-driver v1.12.0 // indirect - go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect - go.opentelemetry.io/otel v1.29.0 // indirect - go.opentelemetry.io/otel/metric v1.29.0 // indirect - go.opentelemetry.io/otel/trace v1.29.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - go.uber.org/ratelimit v0.3.0 // indirect - golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect - golang.org/x/mod v0.21.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.19.0 // indirect - golang.org/x/tools v0.25.0 // indirect - google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect - google.golang.org/grpc v1.67.1 // indirect - google.golang.org/protobuf v1.35.1 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.mongodb.org/mongo-driver v1.13.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/ratelimit v0.3.1 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/tools v0.41.0 // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/ini.v1 v1.67.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +retract v4.30.0 // Problem related to misuse of sycalls by aliyun/credentials-go diff --git a/go.sum b/go.sum index 40bdeb669..f5b87c9fe 100644 --- a/go.sum +++ b/go.sum @@ -13,18 +13,18 @@ cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKV cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go/auth v0.10.0 h1:tWlkvFAh+wwTOzXIjrwM64karR1iTBZ/GRr0S/DULYo= -cloud.google.com/go/auth v0.10.0/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI= -cloud.google.com/go/auth/oauth2adapt v0.2.5 h1:2p29+dePqsCHPP1bqDJcKj4qxRyYCcbzKpFyKGt3MTk= -cloud.google.com/go/auth/oauth2adapt v0.2.5/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8= +cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= +cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo= -cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= @@ -42,14 +42,14 @@ github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 h1:Dy3M9aegiI7d7PF1LUdjbVigJReo+QOceYs github.com/AdamSLevy/jsonrpc2/v14 v14.1.0/go.mod h1:ZakZtbCXxCz82NJvq7MoREtiQesnDfrtF6RFUGzQfLo= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 h1:JZg6HRh6W6U4OLl6lk7BZ7BLisIzM9dG1R50zUk9C/M= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0/go.mod h1:YL1xnZ6QejvQHWJrX/AvhFl4WW4rqHVoKspWNVwFk0M= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 h1:B/dfvscEQtew9dVuoxqxrUKKv8Ih2f55PydknDamU+g= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0/go.mod h1:fiPSssYvltE08HJchL04dOy+RD4hgrjph0cwGGMntdI= -github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0 h1:+m0M/LFxN43KvULkDNfdXOgrjtg6UYJPFBJyuEcRCAw= -github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0/go.mod h1:PwOyop78lveYMRs6oCxjiVyBdyCgIYH6XHIVZO9/SFQ= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= @@ -63,8 +63,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1. github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest/autorest v0.11.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA= -github.com/Azure/go-autorest/autorest v0.11.29 h1:I4+HL/JDvErx2LjyzaVxllw2lRDB5/BT2Bm4g20iqYw= -github.com/Azure/go-autorest/autorest v0.11.29/go.mod h1:ZtEzC4Jy2JDrZLxvWs8LrBWEBycl1hbT1eknI8MtfAs= +github.com/Azure/go-autorest/autorest v0.11.30 h1:iaZ1RGz/ALZtN5eq4Nr1SOFSlf2E4pDI3Tcsl+dZPVE= +github.com/Azure/go-autorest/autorest v0.11.30/go.mod h1:t1kpPIOpIVX7annvothKvb0stsrXa37i7b+xpmBW8Fs= github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= github.com/Azure/go-autorest/autorest/adal v0.9.22 h1:/GblQdIudfEM3AWWZ0mrYJQSd7JS4S/Mbzh6F0ov0Xc= github.com/Azure/go-autorest/autorest/adal v0.9.22/go.mod h1:XuAbAEUv2Tta//+voMI038TrJBqjKam0me7qR+L8Cmk= @@ -77,28 +77,25 @@ github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSY github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= github.com/Azure/go-autorest/autorest/mocks v0.4.2 h1:PGN4EDXnuQbojHbU0UWoNvmu9AGVwYHG9/fkDYhtAfw= github.com/Azure/go-autorest/autorest/mocks v0.4.2/go.mod h1:Vy7OitM9Kei0i1Oj+LvyAWMXJHeKH1MVlzFugfVrmyU= -github.com/Azure/go-autorest/autorest/to v0.4.0 h1:oXVqrxakqqV1UZdSazDOPOLvOIz+XA683u8EctwboHk= -github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcPR4o9jEImooCeWJcYV/zLE= +github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+WNGCVv7OmS5+lTc= +github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M= github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+ZtXWSmf4Tg= github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= -github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= -github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= -github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/HdrHistogram/hdrhistogram-go v1.1.0/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 h1:xPMsUicZ3iosVPSIP7bW5EcGUzjiiMl1OYTe14y/R24= -github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87/go.mod h1:iGLljf5n9GjT6kc0HBvyI1nOKnGQbNB66VzSNbK5iks= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/sarama v1.30.1/go.mod h1:hGgx05L/DiW8XYBXeJdKIN6V2QUy2H6JqME5VT1NLRw= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= @@ -106,69 +103,125 @@ github.com/Shopify/toxiproxy/v2 v2.1.6-0.20210914104332-15ea381dcdae/go.mod h1:/ github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= -github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 h1:F1j7z+/DKEsYqZNoxC6wvfmaiDneLsQOFQmuq9NADSY= -github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2/go.mod h1:QlXr/TrICfQ/ANa76sLeQyhAJyNR9sEcfNuZBkY9jgY= +github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 h1:h/33OxYLqBk0BYmEbSUy7MlvgQR/m1w1/7OJFKoPL1I= +github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0/go.mod h1:rvh3imDA6EaQi+oM/GQHkQAOHbXPKJ7EWJvfjuw141Q= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/aliyun/alibaba-cloud-sdk-go v1.63.47 h1:B8ApNodSpIM5ST9INmhMG4d0rRwNY/63/XjXUDO/XIo= -github.com/aliyun/alibaba-cloud-sdk-go v1.63.47/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ= +github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA= +github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g= +github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY= +github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI= +github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE= +github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8= +github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc= +github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.14/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15 h1:Mubp9hXZMTPWZK+WxrR+kKOVFp4Q/PDZrIIM7ByXI9Y= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE= +github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg= +github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ= +github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo= +github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA= +github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= +github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg= +github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q= +github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= +github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/openapi-util v0.1.1 h1:ujGErJjG8ncRW6XtBBMphzHTvCxn4DjrVw4m04HsS28= +github.com/alibabacloud-go/openapi-util v0.1.1/go.mod h1:/UehBSE2cf1gYT43GV4E+RxTdLRzURImCYY0aRmlXpw= +github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= +github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= +github.com/alibabacloud-go/tea v1.3.13/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg= +github.com/alibabacloud-go/tea v1.4.0 h1:MSKhu/kWLPX7mplWMngki8nNt+CyUZ+kfkzaR5VpMhA= +github.com/alibabacloud-go/tea v1.4.0/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg= +github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= +github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4= +github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= +github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0= +github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= +github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= +github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0= +github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM= +github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U= +github.com/aliyun/credentials-go v1.4.7 h1:T17dLqEtPUFvjDRRb5giVvLh6dFT8IcNFJJb7MeyCxw= +github.com/aliyun/credentials-go v1.4.7/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.3.9/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= -github.com/aws/aws-sdk-go-v2 v1.32.3 h1:T0dRlFBKcdaUPGNtkBSwHZxrtis8CQU17UpNBZYd0wk= -github.com/aws/aws-sdk-go-v2 v1.32.3/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 h1:pT3hpW0cOHRJx8Y0DfJUEQuqPild8jRGmSFmBgvydr0= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6/go.mod h1:j/I2++U0xX+cr44QjHay4Cvxj6FUbnxrgmqN3H1jTZA= -github.com/aws/aws-sdk-go-v2/config v1.28.1 h1:oxIvOUXy8x0U3fR//0eq+RdCKimWI900+SV+10xsCBw= -github.com/aws/aws-sdk-go-v2/config v1.28.1/go.mod h1:bRQcttQJiARbd5JZxw6wG0yIK3eLeSCPdg6uqmmlIiI= -github.com/aws/aws-sdk-go-v2/credentials v1.17.42 h1:sBP0RPjBU4neGpIYyx8mkU2QqLPl5u9cmdTWVzIpHkM= -github.com/aws/aws-sdk-go-v2/credentials v1.17.42/go.mod h1:FwZBfU530dJ26rv9saAbxa9Ej3eF/AK0OAY86k13n4M= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.18 h1:68jFVtt3NulEzojFesM/WVarlFpCaXLKaBxDpzkQ9OQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.18/go.mod h1:Fjnn5jQVIo6VyedMc0/EhPpfNlPl7dHV916O6B+49aE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.22 h1:Jw50LwEkVjuVzE1NzkhNKkBf9cRN7MtE1F/b2cOKTUM= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.22/go.mod h1:Y/SmAyPcOTmpeVaWSzSKiILfXTVJwrGmYZhcRbhWuEY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.22 h1:981MHwBaRZM7+9QSR6XamDzF/o7ouUGxFzr+nVSIhrs= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.22/go.mod h1:1RA1+aBEfn+CAB/Mh0MB6LsdCYCnjZm7tKXtnk499ZQ= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.22 h1:yV+hCAHZZYJQcwAaszoBNwLbPItHvApxT0kVIw6jRgs= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.22/go.mod h1:kbR1TL8llqB1eGnVbybcA4/wgScxdylOdyAd51yxPdw= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs= +github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.8 h1:Jp2JYH1lRT3KhX4mshHPvVYsR5qqRec3hGvEarNYoR0= +github.com/aws/aws-sdk-go-v2/credentials v1.19.8/go.mod h1:fZG9tuvyVfxknv1rKibIz3DobRaFw1Poe8IKtXB3XYY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 h1:TToQNkvGguu209puTojY/ozlqy2d/SFNcoLIqTFi42g= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0/go.mod h1:0jp+ltwkf+SwG2fm/PKo8t4y8pJSgOCO4D8Lz3k0aHQ= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.3 h1:kT6BcZsmMtNkP/iYMcRG+mIEA/IbeiUimXtGmqF39y0= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.3/go.mod h1:Z8uGua2k4PPaGOYn66pK02rhMrot3Xk3tpBuUFPomZU= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.3 h1:qcxX0JYlgWH3hpPUnd6U0ikcl6LLA9sLkXE2w1fpMvY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.3/go.mod h1:cLSNEmI45soc+Ef8K/L+8sEA3A3pYFEYf5B5UI+6bH4= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.3 h1:ZC7Y/XgKUxwqcdhO5LE8P6oGP1eh6xlQReWNKfhvJno= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.3/go.mod h1:WqfO7M9l9yUAw0HcHaikwRd/H6gzYdz7vjejCA5e2oY= -github.com/aws/aws-sdk-go-v2/service/lightsail v1.42.3 h1:lcsqV11EaB74iNKr/PaXV0Og1D/lCZIhIf+kPucTfPw= -github.com/aws/aws-sdk-go-v2/service/lightsail v1.42.3/go.mod h1:IyYNP3fIP5/BvFKqQFj7wwQnKuH0wndcv6j4DyG9pRk= -github.com/aws/aws-sdk-go-v2/service/route53 v1.46.0 h1:AaOWmXBSDSIEsTzx8Y2nYAxckgmBPNiRU5mjn/a9ynI= -github.com/aws/aws-sdk-go-v2/service/route53 v1.46.0/go.mod h1:IN9bx4yLAa3a3J7A41skQefcYObNv6ARAd2i5WxvGKg= -github.com/aws/aws-sdk-go-v2/service/s3 v1.66.2 h1:p9TNFL8bFUMd+38YIpTAXpoxyz0MxC7FlbFEH4P4E1U= -github.com/aws/aws-sdk-go-v2/service/s3 v1.66.2/go.mod h1:fNjyo0Coen9QTwQLWeV6WO2Nytwiu+cCcWaTdKCAqqE= -github.com/aws/aws-sdk-go-v2/service/sso v1.24.3 h1:UTpsIf0loCIWEbrqdLb+0RxnTXfWh2vhw4nQmFi4nPc= -github.com/aws/aws-sdk-go-v2/service/sso v1.24.3/go.mod h1:FZ9j3PFHHAR+w0BSEjK955w5YD2UwB/l/H0yAK3MJvI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.3 h1:2YCmIXv3tmiItw0LlYf6v7gEHebLY45kBEnPezbUKyU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.3/go.mod h1:u19stRyNPxGhj6dRm+Cdgu6N75qnbW7+QN0q0dsAk58= -github.com/aws/aws-sdk-go-v2/service/sts v1.32.3 h1:wVnQ6tigGsRqSWDEEyH6lSAJ9OyFUsSnbaUWChuSGzs= -github.com/aws/aws-sdk-go-v2/service/sts v1.32.3/go.mod h1:VZa9yTFyj4o10YGsmDO4gbQJUvvhY72fhumT8W4LqsE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= +github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11 h1:VM5e5M39zRSs+aT0O9SoxHjUXqXxhbw3Yi0FdMQWPIc= +github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11/go.mod h1:0jvzYPIQGCpnY/dmdaotTk2JH4QuBlnW0oeyrcGLWJ4= +github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 h1:1jIdwWOulae7bBLIgB36OZ0DINACb1wxM6wdGlx4eHE= +github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1/go.mod h1:tE2zGlMIlxWv+7Otap7ctRp3qeKqtnja7DZguj3Vu/Y= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= -github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM= -github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aziontech/azionapi-go-sdk v0.144.0 h1:T+/w18o+FCiZsk3Z0ACBVVe7c/5EGLG15S3P8JfuPfo= +github.com/aziontech/azionapi-go-sdk v0.144.0/go.mod h1:OKxP/R0iVXnJJakYwMhh2BGAXnud8Ruy55Ak9ANuWoU= +github.com/baidubce/bce-sdk-go v0.9.260 h1:1v1+2GTP+NGK3L24rJ+bnoiTaDaIy2YoaUM+ot2GTcw= +github.com/baidubce/bce-sdk-go v0.9.260/go.mod h1:zbYJMQwE4IZuyrJiFO8tO8NbtYiKTFTbwh4eIsqjVdg= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= -github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -182,8 +235,9 @@ github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInq github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -194,12 +248,10 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= -github.com/civo/civogo v0.3.11 h1:mON/fyrV946Sbk6paRtOSGsN+asCgCmHCgArf5xmGxM= -github.com/civo/civogo v0.3.11/go.mod h1:7+GeeFwc4AYTULaEshpT2vIcl3Qq8HPoxA17viX3l6g= github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/cloudflare-go v0.108.0 h1:C4Skfjd8I8X3uEOGmQUT4/iGyZcWdkIU7HwvMoLkEE0= -github.com/cloudflare/cloudflare-go v0.108.0/go.mod h1:m492eNahT/9MsN7Ppnoge8AaI7QhVFtEgVm3I9HJFeU= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= @@ -209,25 +261,21 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/cpu/goacmedns v0.1.1 h1:DM3H2NiN2oam7QljgGY5ygy4yDXhK5Z4JUnqaugs2C4= -github.com/cpu/goacmedns v0.1.1/go.mod h1:MuaouqEhPAHxsbqjgnck5zeghuwBP1dLnPoobeGqugQ= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= -github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= -github.com/dnsimple/dnsimple-go v1.7.0 h1:JKu9xJtZ3SqOC+BuYgAWeab7+EEx0sz422vu8j611ZY= -github.com/dnsimple/dnsimple-go v1.7.0/go.mod h1:EKpuihlWizqYafSnQHGCd/gyvy3HkEQJ7ODB4KdV8T8= +github.com/dnsimple/dnsimple-go/v4 v4.0.0 h1:nUCICZSyZDiiqimAAL+E8XL+0sKGks5VRki5S8XotRo= +github.com/dnsimple/dnsimple-go/v4 v4.0.0/go.mod h1:AXT2yfAFOntJx6iMeo1J/zKBw0ggXFYBt4e97dqqPnc= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= @@ -241,10 +289,11 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/exoscale/egoscale/v3 v3.1.7 h1:Q6p9tOVY0IiOW0fUpaPQWY7ggGEuSPZLAGxFgDd2sCE= -github.com/exoscale/egoscale/v3 v3.1.7/go.mod h1:GHKucK/J26v8PGWztGdhxWNMjrjG9PbelxKCJ4YI11Q= +github.com/exoscale/egoscale/v3 v3.1.33 h1:5Lk/pwZ+K0sjNu9obS0VYPfhZQffRkvvO0BpdPoir4o= +github.com/exoscale/egoscale/v3 v3.1.33/go.mod h1:0iY8OxgHJCS5TKqDNhwOW95JBKCnBZl3YGU4Yt+NqkU= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= @@ -262,20 +311,30 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= -github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-acme/alidns-20150109/v4 v4.7.0 h1:PqJ/wR0JTpL4v0Owu1uM7bPQ1Yww0eQLAuuSdLjjQaQ= +github.com/go-acme/alidns-20150109/v4 v4.7.0/go.mod h1:btQvB6xZoN6ykKB74cPhiR+uvhrEE2AFVXm6RDmCHm0= +github.com/go-acme/esa-20240910/v2 v2.48.0 h1:muSDyhjDTejxUGe3FTthCPCqRaEdYY9cG3N/AmU52Lc= +github.com/go-acme/esa-20240910/v2 v2.48.0/go.mod h1:shPb6hzc1rJL15IJBY8HQ4GZk4E8RC52+52twutEwIg= +github.com/go-acme/jdcloud-sdk-go v1.64.0 h1:AW9j5khk8tRYbpBJPxKmqdwIqgLs2Fz3HUK3hn2YXjs= +github.com/go-acme/jdcloud-sdk-go v1.64.0/go.mod h1:qc/m8HNX1Zgd7GAv2DSEinup8fwy3Ted3/VVx7LB5bU= +github.com/go-acme/tencentclouddnspod v1.3.24 h1:uCSiOW1EJttcnOON+MVVyVDJguFL/Q4NIGkq1CrT9p8= +github.com/go-acme/tencentclouddnspod v1.3.24/go.mod h1:RKcB2wSoZncjBA0OEFj59s1ko1XDy+ZsAtk+9uMxUF0= +github.com/go-acme/tencentedgdeone v1.3.38 h1:5YsVl0H4A+cwtiUqR1eZbKFdr4OWfYp2KYJopifzKyQ= +github.com/go-acme/tencentedgdeone v1.3.38/go.mod h1:yyjTKVmGpMtFv5HqGODqehHnZJ4KWAbG6dAiwWDgCDY= github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s= github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= -github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEaizzs= @@ -286,53 +345,59 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE= -github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g= -github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0= +github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= +github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA= github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw= -github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= -github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.9.8 h1:5gMyLUeU1/6zl+WFfR1hN7D2kf+1/eRGa7DFtToiBvQ= +github.com/goccy/go-yaml v1.9.8/go.mod h1:JubOolP3gh0HpiBc4BLRD4YmjEjHAmIIB2aaXKkTfoE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= -github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= -github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= -github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -357,6 +422,8 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= @@ -376,12 +443,14 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -392,28 +461,29 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= -github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= -github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s= -github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gophercloud/gophercloud v1.3.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= github.com/gophercloud/gophercloud v1.14.1 h1:DTCNaTVGl8/cFu58O1JwWgis9gtISAFONqpMKNg/Vpw= github.com/gophercloud/gophercloud v1.14.1/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 h1:sH7xkTfYzxIEgzq1tDHIMKRh1vThOEOGNsettdEeLbE= github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56/go.mod h1:VSalo4adEk+3sNkmVJLnhHoOyOYYS8sTWLG4mv5BKto= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= @@ -425,13 +495,10 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmg github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= -github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= @@ -446,11 +513,9 @@ github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjh github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= -github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= -github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= @@ -460,8 +525,8 @@ github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= -github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= @@ -476,8 +541,8 @@ github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOn github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.120 h1:i+rlH2xzkEMGbol86Fq/ioxgAaOnX2vkH4i/bLptc5s= -github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.120/go.mod h1:JWz2ujO9X3oU5wb6kXp+DpR2UuDj2SldDbX8T0FSuhI= +github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 h1:J+U6+eUjIsBhefolFdZW5hQNJbkMj+7msxZrv56Cg2g= +github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI= github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -485,20 +550,18 @@ github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df h1:MZf03xP9WdakyXhOWuAD5 github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df/go.mod h1:QMZY7/J/KSQEhKWFeDesPjMj+wCHReeknARU3wqlyN4= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= -github.com/infobloxopen/infoblox-go-client v1.1.1 h1:728A6LbLjptj/7kZjHyIxQnm768PWHfGFm0HH8FnbtU= -github.com/infobloxopen/infoblox-go-client v1.1.1/go.mod h1:BXiw7S2b9qJoM8MS40vfgCNB2NLHGusk1DtO16BD9zI= +github.com/infobloxopen/infoblox-go-client/v2 v2.10.0 h1:AKsihjFT/t6Y0keEv3p59DACcOuh0inWXdUB0ZOzYH0= +github.com/infobloxopen/infoblox-go-client/v2 v2.10.0/go.mod h1:NeNJpz09efw/edzqkVivGv1bWqBXTomqYBRFbP+XBqg= github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= -github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= -github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= +github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= +github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= @@ -509,8 +572,9 @@ github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= @@ -519,14 +583,13 @@ github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8 github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= -github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs= -github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00= github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -548,10 +611,13 @@ github.com/labbsr0x/bindman-dns-webhook v1.0.2 h1:I7ITbmQPAVwrDdhd6dHKi+MYJTJqPC github.com/labbsr0x/bindman-dns-webhook v1.0.2/go.mod h1:p6b+VCXIR8NYKpDr8/dg1HKfQoRHCdcsROXKvmoehKA= github.com/labbsr0x/goh v1.0.1 h1:97aBJkDjpyBZGPbQuOK5/gHcSFbcr5aRsq3RSRJFpPk= github.com/labbsr0x/goh v1.0.1/go.mod h1:8K2UhVoaWXcCU7Lxoa2omWnC8gyW8px7/lmO61c027w= -github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= -github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= -github.com/linode/linodego v1.42.0 h1:ZSbi4MtvwrfB9Y6bknesorvvueBGGilcmh2D5dq76RM= -github.com/linode/linodego v1.42.0/go.mod h1:2yzmY6pegPBDgx2HDllmt0eIk2IlzqcgK6NR0wFCFRY= +github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o= +github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/linode/linodego v1.65.0 h1:SdsuGD8VSsPWeShXpE7ihl5vec+fD3MgwhnfYC/rj7k= +github.com/linode/linodego v1.65.0/go.mod h1:tOFiTErdjkbVnV+4S0+NmIE9dqqZUEM2HsJaGu8wMh8= github.com/liquidweb/go-lwApi v0.0.0-20190605172801-52a4864d2738/go.mod h1:0sYF9rMXb0vlG+4SzdiGMXHheCZxjguMq+Zb4S2BfBs= github.com/liquidweb/liquidweb-cli v0.6.9 h1:acbIvdRauiwbxIsOCEMXGwF75aSJDbDiyAWPjVnwoYM= github.com/liquidweb/liquidweb-cli v0.6.9/go.mod h1:cE1uvQ+x24NGUL75D0QagOFCG8Wdvmwu8aL9TLmA/eQ= @@ -574,6 +640,7 @@ github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcME github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= @@ -586,8 +653,8 @@ github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3N github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= github.com/miekg/dns v1.1.47/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= -github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= -github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/mimuret/golang-iij-dpf v0.9.1 h1:Gj6EhHJkOhr+q2RnvRPJsPMcjuVnWPSccEHyoEehU34= github.com/mimuret/golang-iij-dpf v0.9.1/go.mod h1:sl9KyOkESib9+KRD3HaGpgi1xk7eoN2+d96LCLsME2M= github.com/minio/highwayhash v1.0.1/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= @@ -618,8 +685,8 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 h1:o6uBwrhM5C8Ll3MAAxrQxRHEu7FkapwTuI2WmL1rw4g= -github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04/go.mod h1:5sN+Lt1CaY4wsPvgQH/jsuJi4XO2ssZbdsIizr4CVC8= +github.com/namedotcom/go/v4 v4.0.2 h1:4gNkPaPRG/2tqFNUUof7jAVsA6vDutFutEOd7ivnDwA= +github.com/namedotcom/go/v4 v4.0.2/go.mod h1:J6sVueHMb0qbarPgdhrzEVhEaYp+R1SCaTGl2s6/J1Q= github.com/nats-io/jwt v1.2.2/go.mod h1:/xX356yQA6LuXI9xWW7mZNpxgF2mBmGecH+Fj34sP5Q= github.com/nats-io/jwt/v2 v2.0.3/go.mod h1:VRP+deawSXyhNjXmxPCHskrR6Mq50BqpEI5SEcNiGlY= github.com/nats-io/nats-server/v2 v2.5.0/go.mod h1:Kj86UtrXAL6LwYRA6H4RqzkHhK0Vcv2ZnKD5WbQ1t3g= @@ -627,28 +694,35 @@ github.com/nats-io/nats.go v1.12.1/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/ github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s= github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/nrdcg/auroradns v1.1.0 h1:KekGh8kmf2MNwqZVVYo/fw/ZONt8QMEmbMFOeljteWo= -github.com/nrdcg/auroradns v1.1.0/go.mod h1:O7tViUZbAcnykVnrGkXzIJTHoQCHcgalgAe6X1mzHfk= -github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3 h1:ouZ2JWDl8IW5k1qugYbmpbmW8hn85Ig6buSMBRlz3KI= -github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3/go.mod h1:ZwadWt7mVhMHMbAQ1w8IhDqtWO3eWqWq72W7trnaiE8= -github.com/nrdcg/desec v0.8.0 h1:FJbRWUAluTCUi9nHFnhqPhLSIHiNnB9elZVWYgFtIqA= -github.com/nrdcg/desec v0.8.0/go.mod h1:BsnYPtSlBttJL3Gyzv0kDH7zkk60obwThlnqiiKzn+o= +github.com/nrdcg/auroradns v1.2.0 h1:Jg407vTdXZvZKsART9CNWMp8rQOyhBk04q0MsOf0YR4= +github.com/nrdcg/auroradns v1.2.0/go.mod h1:hnByA4Z7MOmV4EPRw5eOmEaNRFavcCIz6kONpNxp9LI= +github.com/nrdcg/bunny-go v0.1.0 h1:GAHTRpHaG/TxfLZlqoJ8OJFzw8rI74+jOTkzxWh0uHA= +github.com/nrdcg/bunny-go v0.1.0/go.mod h1:u+C9dgsspgtWVaAz6QkyV17s9fxD8viwwKoxb9XMz1A= +github.com/nrdcg/desec v0.11.1 h1:ilpKmCr4gGsLcyq3RHfHNmlRzm9fzT2XbWxoVaUCS0s= +github.com/nrdcg/desec v0.11.1/go.mod h1:2LuxHlOcwML/7cntu0eimONmA1U+ZxFDAonoSXr4igQ= github.com/nrdcg/dnspod-go v0.4.0 h1:c/jn1mLZNKF3/osJ6mz3QPxTudvPArXTjpkmYj0uK6U= github.com/nrdcg/dnspod-go v0.4.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ= -github.com/nrdcg/freemyip v0.2.0 h1:/GscavT4GVqAY13HExl5UyoB4wlchv6Cg5NYDGsUoJ8= -github.com/nrdcg/freemyip v0.2.0/go.mod h1:HjF0Yz0lSb37HD2ihIyGz9esyGcxbCrrGFLPpKevbx4= -github.com/nrdcg/goinwx v0.10.0 h1:6W630bjDxQD6OuXKqrFRYVpTt0G/9GXXm3CeOrN0zJM= -github.com/nrdcg/goinwx v0.10.0/go.mod h1:mnMSTi7CXBu2io4DzdOBoGFA1XclD0sEPWJaDhNgkA4= -github.com/nrdcg/mailinabox v0.2.0 h1:IKq8mfKiVwNW2hQii/ng1dJ4yYMMv3HAP3fMFIq2CFk= -github.com/nrdcg/mailinabox v0.2.0/go.mod h1:0yxqeYOiGyxAu7Sb94eMxHPIOsPYXAjTeA9ZhePhGnc= -github.com/nrdcg/namesilo v0.2.1 h1:kLjCjsufdW/IlC+iSfAqj0iQGgKjlbUUeDJio5Y6eMg= -github.com/nrdcg/namesilo v0.2.1/go.mod h1:lwMvfQTyYq+BbjJd30ylEG4GPSS6PII0Tia4rRpRiyw= +github.com/nrdcg/freemyip v0.3.0 h1:0D2rXgvLwe2RRaVIjyUcQ4S26+cIS2iFwnhzDsEuuwc= +github.com/nrdcg/freemyip v0.3.0/go.mod h1:c1PscDvA0ukBF0dwelU/IwOakNKnVxetpAQ863RMJoM= +github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0= +github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg= +github.com/nrdcg/goinwx v0.12.0 h1:ujdUqDBnaRSFwzVnImvPHYw3w3m9XgmGImNUw1GyMb4= +github.com/nrdcg/goinwx v0.12.0/go.mod h1:IrVKd3ZDbFiMjdPgML4CSxZAY9wOoqLvH44zv3NodJ0= +github.com/nrdcg/mailinabox v0.3.0 h1:PHkC1elKXKAjEvdx2HHFMgcEGZFqudAl7aU3L2JDhM4= +github.com/nrdcg/mailinabox v0.3.0/go.mod h1:1eFIGcM4lI+AfFOUpbs548SFGz1ZWoMOGbECBmkghw4= +github.com/nrdcg/namesilo v0.5.0 h1:6QNxT/XxE+f5B+7QlfWorthNzOzcGlBLRQxqi6YeBrE= +github.com/nrdcg/namesilo v0.5.0/go.mod h1:4UkwlwQfDt74kSGmhLaDylnBrD94IfflnpoEaj6T2qw= github.com/nrdcg/nodion v0.1.0 h1:zLKaqTn2X0aDuBHHfyA1zFgeZfiCpmu/O9DM73okavw= github.com/nrdcg/nodion v0.1.0/go.mod h1:inbuh3neCtIWlMPZHtEpe43TmRXxHV6+hk97iCZicms= +github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2 h1:OWijzl3nHUApvTivl+3+78dbBwmyEHOnb+W9m6ixGbk= +github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8= +github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2 h1:9LsjN/zaIN7H8JE61NHpbWhxF0UGY96+kMlk3g8OvGU= +github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2/go.mod h1:32vZH06TuwZSn+IDMO1qcDvC2vHVlzUALCwXGWPA+dc= github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw= github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54= +github.com/nrdcg/vegadns v0.3.0 h1:11FQMw7xVIRUWO9o5+Z/5YZhmPWlm4oxUUH3F6EVqQU= +github.com/nrdcg/vegadns v0.3.0/go.mod h1:NqSyRKZuJlAsv8VI/7rSubfPXN68NwaJ0aG9KxQVFVo= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= @@ -663,35 +737,30 @@ github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/ginkgo/v2 v2.15.0 h1:79HwNRBAZHOEwrczrgSOPy+eFTTlIGELKy5as+ClttY= -github.com/onsi/ginkgo/v2 v2.15.0/go.mod h1:HlxMHtYF57y6Dpf+mc5529KKmSq9h2FpCF+/ZkwUxKM= +github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0= +github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= -github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/onsi/gomega v1.31.1 h1:KYppCUK+bUgAZwHOu7EXVBKyQA6ILvOESHkn/tgoqvo= -github.com/onsi/gomega v1.31.1/go.mod h1:y40C95dwAD1Nz36SsEnxvfFe8FFfNxzI5eJ0EYGyAy0= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= -github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= -github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa4YDFlwRYAMyE= -github.com/oracle/oci-go-sdk/v65 v65.77.1 h1:gqjTXIUWvTihkn470AclxSAMcR1JecqjD2IUtp+sDIU= -github.com/oracle/oci-go-sdk/v65 v65.77.1/go.mod h1:IBEV9l1qBzUpo7zgGaRUhbB05BVfcDGYRFBCPlTcPp0= -github.com/ovh/go-ovh v1.6.0 h1:ixLOwxQdzYDx296sXcgS35TOPEahJkpjMGtzPadCjQI= -github.com/ovh/go-ovh v1.6.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c= +github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE= +github.com/ovh/go-ovh v1.9.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= -github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/performancecopilot/speed/v4 v4.0.0/go.mod h1:qxrSyuDGrTOWfV+uKRFhfxw6h/4HXRGUiZiufxo49BM= +github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c= +github.com/peterhellberg/link v1.2.0/go.mod h1:gYfAh+oJgQu2SrZHg5hROVRQe1ICoK0/HHJTcE0edxc= github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -708,8 +777,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= -github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= -github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= @@ -742,40 +811,38 @@ github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 h1:dq90+d51/hQR github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2/go.mod h1:7tZKcyumwBO6qip7RNQ5r77yrssm9bfCowcLEBcU5IA= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= -github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= github.com/regfish/regfish-dnsapi-go v0.1.1 h1:TJFtbePHkd47q5GZwYl1h3DIYXmoxdLjW/SBsPtB5IE= github.com/regfish/regfish-dnsapi-go v0.1.1/go.mod h1:ubIgXSfqarSnl3XHSn8hIFwFF3h0yrq0ZiWD93Y2VjY= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sacloud/api-client-go v0.2.10 h1:+rv3jDohD+pkdYwOTBiB+jZsM0xK3AxadXRzhp3q66c= -github.com/sacloud/api-client-go v0.2.10/go.mod h1:Jj3CTy2+O4bcMedVDXlbHuqqche85HEPuVXoQFhLaRc= -github.com/sacloud/go-http v0.1.8 h1:ynreWA/vnM8G2ksbMlmefBHsXURKPz49qlPRqQ9IQdw= -github.com/sacloud/go-http v0.1.8/go.mod h1:7TL7TN1fnPKHsMifIqURDkGujnKViCgEz5Ei/LQdFK8= -github.com/sacloud/iaas-api-go v1.12.0 h1:kqXFn3HzCiawlX6hVJb1GVqcSJqcmiGHB4Zp14sxiI8= -github.com/sacloud/iaas-api-go v1.12.0/go.mod h1:SZLXeWOdXk3WReIS557sbU1gkOgrE4rseIBQV1B3b7o= -github.com/sacloud/packages-go v0.0.10 h1:UiQGjy8LretewkRhsuna1TBM9Vz/l9FoYpQx+D+AOck= -github.com/sacloud/packages-go v0.0.10/go.mod h1:f8QITBh9z4IZc4yE9j21Q8b0sXEMwRlRmhhjWeDVTYs= +github.com/sacloud/api-client-go v0.3.3 h1:ZpSAyGpITA8UFO3Hq4qMHZLGuNI1FgxAxo4sqBnCKDs= +github.com/sacloud/api-client-go v0.3.3/go.mod h1:0p3ukcWYXRCc2AUWTl1aA+3sXLvurvvDqhRaLZRLBwo= +github.com/sacloud/go-http v0.1.9 h1:Xa5PY8/pb7XWhwG9nAeXSrYXPbtfBWqawgzxD5co3VE= +github.com/sacloud/go-http v0.1.9/go.mod h1:DpDG+MSyxYaBwPJ7l3aKLMzwYdTVtC5Bo63HActcgoE= +github.com/sacloud/iaas-api-go v1.23.1 h1:rjYG0vVoxWyETiwc7R8YdD7CIzs9vVNEOzu7w6dgGzc= +github.com/sacloud/iaas-api-go v1.23.1/go.mod h1:EGIHOWRB9azOv7HPCVM8WpOEl28WIV9TNRbnEVg+Q3U= +github.com/sacloud/packages-go v0.0.12 h1:MKeZNN3FQn1heqUSRBrbZw89YusZA1n4kammjMFZYvQ= +github.com/sacloud/packages-go v0.0.12/go.mod h1:XNF5MCTWcHo9NiqWnYctVbASSSZR3ZOmmQORIzcurJ8= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/scaleway/scaleway-sdk-go v1.0.0-beta.30 h1:yoKAVkEVwAqbGbR8n87rHQ1dulL25rKloGadb3vm770= -github.com/scaleway/scaleway-sdk-go v1.0.0-beta.30/go.mod h1:sH0u6fq6x4R5M7WxkoQFY/o7UaiItec0o1LinLCJNq8= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/selectel/domains-go v1.1.0 h1:futG50J43ALLKQAnZk9H9yOtLGnSUh7c5hSvuC5gSHo= github.com/selectel/domains-go v1.1.0/go.mod h1:SugRKfq4sTpnOHquslCpzda72wV8u0cMBHx0C0l+bzA= -github.com/selectel/go-selvpcclient/v3 v3.1.1 h1:C1q2LqqosiapoLpnGITGmysg0YCSQYDo2Gh69CioevM= -github.com/selectel/go-selvpcclient/v3 v3.1.1/go.mod h1:NM7IXhh1IzqZ88DOw1Qc5Ez3tULLViXo95l5+rKPuyQ= -github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= -github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/selectel/go-selvpcclient/v4 v4.1.0 h1:22lBp+rzg9g2MP4iiGhpVAcCt0kMv7I7uV1W3taLSvQ= +github.com/selectel/go-selvpcclient/v4 v4.1.0/go.mod h1:eFhL1KUW159KOJVeGO7k/Uxl0TYd/sBkWXjuF5WxmYk= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -784,21 +851,16 @@ github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= -github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= -github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 h1:hp2CYQUINdZMHdvTdXtPOY2ainKl4IoMcpAXEf2xj3Q= -github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= +github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/smartystreets/gunit v1.0.4 h1:tpTjnuH7MLlqhoD21vRoMZbMIi5GmBsAJDFyF67GhZA= -github.com/smartystreets/gunit v1.0.4/go.mod h1:EH5qMBab2UclzXUcpR8b93eHsIlp9u+pDQIRp5DZNzQ= -github.com/softlayer/softlayer-go v1.1.7 h1:SgTL+pQZt1h+5QkAhVmHORM/7N9c1X0sljJhuOIHxWE= -github.com/softlayer/softlayer-go v1.1.7/go.mod h1:WeJrBLoTJcaT8nO1azeyHyNpo/fDLtbpbvh+pzts+Qw= +github.com/softlayer/softlayer-go v1.2.1 h1:8ucHxn5laVsVPb0/aMGnr6tOMt1I9BgEtU5mn70OGKw= +github.com/softlayer/softlayer-go v1.2.1/go.mod h1:Gz9/ktcmB7Z8EJlu+QEJJpkv8lAmnhYdB9Tc6gedjmo= github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e h1:3OgWYFw7jxCZPcvAg+4R8A50GZ+CCkARF10lxu2qDsQ= github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e/go.mod h1:fKZCUVdirrxrBpwd9wb+lSoVixvpwAu8eHzbQB2tums= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= -github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg= -github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= +github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= +github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -808,14 +870,15 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= @@ -825,6 +888,7 @@ github.com/streadway/amqp v1.0.0/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1Sd github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= @@ -837,57 +901,55 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1034 h1:T7ewuO2DD+5R2LRpD2kTRy25aCkVDVdYkmmyUS63i08= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1034/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1034 h1:hXxv58/eSlDj80n0P0ISXh91pC/2vqurJNwn5SpXFPI= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1034/go.mod h1:hwTIplwF9IYWz5HQcyw0+R8aqJB0lEZB8sI0pIA5Htw= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.24/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.38/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48 h1:bCs+z6dxRaHWm/C1D/XkSOcCZ0+W2+/6HmIXjpAj+fY= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/transip/gotransip/v6 v6.26.0 h1:Aejfvh8rSp8Mj2GX/RpdBjMCv+Iy/DmgfNgczPDP550= -github.com/transip/gotransip/v6 v6.26.0/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s= +github.com/transip/gotransip/v6 v6.26.1 h1:MeqIjkTBBsZwWAK6giZyMkqLmKMclVHEuTNmoBdx4MA= +github.com/transip/gotransip/v6 v6.26.1/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= -github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= -github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= -github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg= -github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= -github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec h1:2s/ghQ8wKE+UzD/hf3P4Gd1j0JI9ncbxv+nsypPoUYI= -github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec/go.mod h1:BZr7Qs3ku1ckpqed8tCRSqTlp8NAeZfAVpfx4OzXMss= +github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419 h1:/VaznPrb/b68e3iMvkr27fU7JqPKU4j7tIITZnjQX1k= +github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419/go.mod h1:QN0/PdenvYWB0GRMz6JJbPeZz2Lph2iys1p8AFVHm2c= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= -github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= -github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= -github.com/vinyldns/go-vinyldns v0.9.16 h1:GZJStDkcCk1F1AcRc64LuuMh+ENL8pHA0CVd4ulRMcQ= -github.com/vinyldns/go-vinyldns v0.9.16/go.mod h1:5qIJOdmzAnatKjurI+Tl4uTus7GJKJxb+zitufjHs3Q= -github.com/volcengine/volc-sdk-golang v1.0.183 h1:V6M/lhgnBxZS3pLDNwMXSLw+i4VowphNCfVzai6JjWE= -github.com/volcengine/volc-sdk-golang v1.0.183/go.mod h1:u0VtPvlXWpXDTmc9IHkaW1q+5Jjwus4oAqRhNMDRInE= -github.com/vultr/govultr/v3 v3.9.1 h1:uxSIb8Miel7tqTs3ee+z3t+JelZikwqBBsZzCOPBy/8= -github.com/vultr/govultr/v3 v3.9.1/go.mod h1:Rd8ebpXm7jxH3MDmhnEs+zrlYW212ouhx+HeUMfHm2o= +github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= +github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= +github.com/vinyldns/go-vinyldns v0.9.17 h1:hfPZfCaxcRBX6Gsgl42rLCeoal58/BH8kkvJShzjjdI= +github.com/vinyldns/go-vinyldns v0.9.17/go.mod h1:pwWhE9K/leGDOIduVhRGvQ3ecVMHWRfEnKYUTEU3gB4= +github.com/volcengine/volc-sdk-golang v1.0.237 h1:hpLKiS2BwDcSBtZWSz034foCbd0h3FrHTKlUMqHIdc4= +github.com/volcengine/volc-sdk-golang v1.0.237/go.mod h1:zHJlaqiMbIB+0mcrsZPTwOb3FB7S/0MCfqlnO8R7hlM= +github.com/vultr/govultr/v3 v3.27.0 h1:J8etMyu/Jh5+idMsu2YZpOWmDXXHeW4VZnkYXmJYHx8= +github.com/vultr/govultr/v3 v3.27.0/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= -github.com/yandex-cloud/go-genproto v0.0.0-20241101135610-76a0cfc1a773 h1:xkWrnYFWxiwCKVbmuOEMR030UCFklpglmOcPv9yJz2c= -github.com/yandex-cloud/go-genproto v0.0.0-20241101135610-76a0cfc1a773/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo= -github.com/yandex-cloud/go-sdk v0.0.0-20241101143304-947cf519f6bd h1:LcA5pQoWjS2hhG6bV2ZL9eBEV2wLSVbM2KcpDphYP/w= -github.com/yandex-cloud/go-sdk v0.0.0-20241101143304-947cf519f6bd/go.mod h1:oku4OkbdLLOOpZEz2XxYGXI7rFhxBI5W0cLPmpStdqA= +github.com/yandex-cloud/go-genproto v0.54.0 h1:LjEwDPBAtF39HvcPQe8I+ImCnFasCPCOVh2b2Sr2eAg= +github.com/yandex-cloud/go-genproto v0.54.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo= +github.com/yandex-cloud/go-sdk/services/dns v0.0.36 h1:sD622+baDvJ2ujhCfoFsCH0XeNsaZNW6loRqvRavjtE= +github.com/yandex-cloud/go-sdk/services/dns v0.0.36/go.mod h1:Hh7IKJxULaRzmyM19lQZw+yUDyMM8M3Qrk1LbWqhCkc= +github.com/yandex-cloud/go-sdk/v2 v2.56.0 h1:rihPAZbPbHU/BKTLuT64nU1uhbBrO20HhdlLR3Hyoz0= +github.com/yandex-cloud/go-sdk/v2 v2.56.0/go.mod h1:jzVBQgamNHoiDsmjog2dPZHMXuGZqmxf/epH+Qb7Emc= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= @@ -897,40 +959,50 @@ go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQc go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lLS/oTh0= -go.mongodb.org/mongo-driver v1.12.0 h1:aPx33jmn/rQuJXPQLZQ8NtfPQG8CaqgLThFtqRb0PiE= -go.mongodb.org/mongo-driver v1.12.0/go.mod h1:AZkxhPnFJUoH7kZlFkVKucV20K387miPfm7oimrSmK0= +go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk= +go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= -go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= -go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= -go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= -go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= -go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= -go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -go.uber.org/ratelimit v0.3.0 h1:IdZd9wqvFXnvLvSEBo0KPcGfkoBGNkpTHlrE3Rcjkjw= -go.uber.org/ratelimit v0.3.0/go.mod h1:So5LG7CV1zWpY1sHe+DXTJqQvOx+FFPFaAs2SnoyBaI= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0= +go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -939,7 +1011,9 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -952,13 +1026,17 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= -golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -972,8 +1050,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE= -golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU= +golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -999,8 +1077,11 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= -golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1046,23 +1127,28 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= -golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1076,8 +1162,11 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1119,6 +1208,7 @@ golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1143,30 +1233,43 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= -golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1181,19 +1284,20 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= -golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= -golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1234,6 +1338,7 @@ golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjs golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -1248,14 +1353,20 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= -golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= @@ -1274,8 +1385,8 @@ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0M google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.204.0 h1:3PjmQQEDkR/ENVZZwIYB4W/KzYtN8OrqnNcHWpeR8E4= -google.golang.org/api v0.204.0/go.mod h1:69y8QSoKIbL9F94bWgWAq6wGqGwyjBgi2y8rAK8zLag= +google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE= +google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -1314,12 +1425,12 @@ google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 h1:Q3nlH8iSQSRUwOskjbcSMcF2jiYMNiQYZ0c2KEJLKKU= -google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38/go.mod h1:xBI+tzfqGGN2JBeSebfKXFSdBpWVQ7sLW40PTupVRm4= -google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 h1:fVoAXEKA4+yufmbdVYv+SE73+cPZbbbe8paLsHfkK+U= -google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53/go.mod h1:riSXTwQ4+nqmPGtobMFyW5FqVAmIs0St6VPp4Ug7CE4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 h1:zciRKQ4kBpFgpfC5QQCVtnnNAcLIqweL7plyZRQHVpI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1337,8 +1448,8 @@ google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= -google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -1353,8 +1464,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -1365,16 +1476,15 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= -gopkg.in/h2non/gock.v1 v1.0.15 h1:SzLqcIlb/fDfg7UvukMpNcWsu7sI5tWwL+KCATZqks0= -gopkg.in/h2non/gock.v1 v1.0.15/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= +gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= -gopkg.in/ns1/ns1-go.v2 v2.12.2 h1:SPM5BTTMJ1zVBhMMiiPFdF7l6Y3fq5o7bKM7jDqsUfM= -gopkg.in/ns1/ns1-go.v2 v2.12.2/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc= +gopkg.in/ns1/ns1-go.v2 v2.17.2 h1:x8YKHqCJWkC/hddfUhw7FRqTG0x3fr/0ZnWYN+i4THs= +gopkg.in/ns1/ns1-go.v2 v2.17.2/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= @@ -1391,7 +1501,6 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -1406,5 +1515,5 @@ rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= -software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= -software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0= +software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/clihelp/generator.go b/internal/clihelp/generator.go index 2d256b4d7..fcabde015 100644 --- a/internal/clihelp/generator.go +++ b/internal/clihelp/generator.go @@ -50,6 +50,7 @@ func generate() error { // collect output of various help pages var help []commandHelp + for _, args := range [][]string{ {"lego", "help"}, {"lego", "help", "run"}, @@ -72,7 +73,9 @@ func generate() error { } err = outputTpl.Execute(f, help) + defer func() { _ = f.Close() }() + if err != nil { return fmt.Errorf("failed to write cli_help.toml: %w", err) } @@ -98,9 +101,11 @@ func createStubApp() *cli.App { func run(app *cli.App, args []string) (h commandHelp, err error) { w := app.Writer + defer func() { app.Writer = w }() var buf bytes.Buffer + app.Writer = &buf if err := app.Run(args); err != nil { diff --git a/internal/dns/docs/generator.go b/internal/dns/docs/generator.go index a6b91b45d..9355d0d1b 100644 --- a/internal/dns/docs/generator.go +++ b/internal/dns/docs/generator.go @@ -48,6 +48,11 @@ func main() { log.Fatal(err) } + err = cleanDocumentation() + if err != nil { + log.Fatal(err) + } + for _, m := range models.Providers { // generate documentation err = generateDocumentation(m) @@ -71,6 +76,22 @@ func main() { fmt.Printf("Documentation for %d DNS providers has been generated.\n", len(models.Providers)+1) } +func cleanDocumentation() error { + paths, err := filepath.Glob(filepath.Join(docOutput, "zz_gen_*.md")) + if err != nil { + return err + } + + for _, p := range paths { + err = os.RemoveAll(p) + if err != nil { + return err + } + } + + return nil +} + func generateDocumentation(m descriptors.Provider) error { filename := filepath.Join(docOutput, "zz_gen_"+m.Code+".md") @@ -95,8 +116,9 @@ func generateCLIHelp(models *descriptors.Providers) error { defer func() { _ = file.Close() }() b := &bytes.Buffer{} + err = template.Must( - template.New(filepath.Base(cliTemplate)).Funcs(map[string]interface{}{ + template.New(filepath.Base(cliTemplate)).Funcs(map[string]any{ "safe": func(src string) string { return strings.ReplaceAll(src, "`", "'") }, @@ -113,6 +135,7 @@ func generateCLIHelp(models *descriptors.Providers) error { } _, err = file.Write(source) + return err } @@ -140,6 +163,7 @@ func generateReadMe(models *descriptors.Providers) error { if err = tpl.Execute(buffer, providers); err != nil { return err } + skip = true } @@ -166,31 +190,29 @@ func generateReadMe(models *descriptors.Providers) error { } func orderProviders(models *descriptors.Providers) [][]descriptors.Provider { - providers := append(models.Providers, descriptors.Provider{ - Name: "Manual", - Code: "manual", - }) - const nbCol = 4 - slices.SortFunc(providers, func(a, b descriptors.Provider) int { + slices.SortFunc(models.Providers, func(a, b descriptors.Provider) int { return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) - var matrix [][]descriptors.Provider - var row []descriptors.Provider + var ( + matrix [][]descriptors.Provider + row []descriptors.Provider + ) - for i, p := range providers { + for i, p := range models.Providers { switch { case len(row) == nbCol: matrix = append(matrix, row) row = []descriptors.Provider{p} - case i == len(providers)-1: + case i == len(models.Providers)-1: row = append(row, p) for j := len(row); j < nbCol; j++ { row = append(row, descriptors.Provider{}) } + matrix = append(matrix, row) default: @@ -202,6 +224,7 @@ func orderProviders(models *descriptors.Providers) [][]descriptors.Provider { for j := len(row); j < nbCol; j++ { row = append(row, descriptors.Provider{}) } + matrix = append(matrix, row) } diff --git a/internal/dns/docs/templates/dns.go.tmpl b/internal/dns/docs/templates/dns.go.tmpl index e8b336254..c1896c91a 100644 --- a/internal/dns/docs/templates/dns.go.tmpl +++ b/internal/dns/docs/templates/dns.go.tmpl @@ -12,7 +12,6 @@ import ( func allDNSCodes() string { providers := []string{ - "manual", {{- range $provider := .Providers }} "{{ $provider.Code }}", {{- end}} @@ -48,8 +47,6 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/{{ $provider.Code }}`) {{end}} - case "manual": - ew.writeln(`Solving the DNS-01 challenge using CLI prompt.`) default: return fmt.Errorf("%q is not yet supported", name) } diff --git a/internal/dns/providers/dns_providers.go.tmpl b/internal/dns/providers/dns_providers.go.tmpl index 2030a3ed0..c974ef6a9 100644 --- a/internal/dns/providers/dns_providers.go.tmpl +++ b/internal/dns/providers/dns_providers.go.tmpl @@ -6,7 +6,6 @@ import ( "fmt" "github.com/go-acme/lego/v4/challenge" - "github.com/go-acme/lego/v4/challenge/dns01" {{- range $provider := .Providers }} "github.com/go-acme/lego/v4/providers/dns/{{ cleanName $provider.Code }}" {{- end}} @@ -15,8 +14,6 @@ import ( // NewDNSChallengeProviderByName Factory for DNS providers. func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { switch name { - case "manual": - return dns01.NewDNSProviderManual() {{- range $provider := .Providers }} case "{{ $provider.Code }}"{{range $alias := $provider.Aliases }},"{{ $alias }}"{{end}}: return {{ cleanName $provider.Code }}.NewDNSProvider() diff --git a/internal/dns/providers/generator.go b/internal/dns/providers/generator.go index bab31072d..df3f8a2e6 100644 --- a/internal/dns/providers/generator.go +++ b/internal/dns/providers/generator.go @@ -46,8 +46,9 @@ func generate() error { defer func() { _ = file.Close() }() b := &bytes.Buffer{} + err = template.Must( - template.New("").Funcs(map[string]interface{}{ + template.New("").Funcs(map[string]any{ "cleanName": func(src string) string { return strings.ReplaceAll(src, "-", "") }, diff --git a/internal/releaser/generator.go b/internal/releaser/generator.go index d1b3e74e1..f24aea25f 100644 --- a/internal/releaser/generator.go +++ b/internal/releaser/generator.go @@ -33,7 +33,7 @@ type Generator struct { targetFile string } -func NewGenerator(templatePath string, targetFile string) *Generator { +func NewGenerator(templatePath, targetFile string) *Generator { return &Generator{templatePath: templatePath, targetFile: targetFile} } diff --git a/internal/releaser/releaser.go b/internal/releaser/releaser.go index 6047c427c..57b463933 100644 --- a/internal/releaser/releaser.go +++ b/internal/releaser/releaser.go @@ -108,6 +108,7 @@ func detach(_ *cli.Context) error { func readCurrentVersion(filename string) (*hcversion.Version, error) { fset := token.NewFileSet() + file, err := parser.ParseFile(fset, filename, nil, parser.AllErrors) if err != nil { return nil, err @@ -141,6 +142,7 @@ func (v visitor) Visit(n ast.Node) ast.Visitor { if !ok { continue } + if len(valueSpec.Names) != 1 || len(valueSpec.Values) != 1 { continue } @@ -149,6 +151,7 @@ func (v visitor) Visit(n ast.Node) ast.Visitor { if !ok { continue } + if va.Kind != token.STRING { continue } @@ -164,6 +167,7 @@ func (v visitor) Visit(n ast.Node) ast.Visitor { default: // noop } + return v } diff --git a/lego/client.go b/lego/client.go index 1109e1224..d06956203 100644 --- a/lego/client.go +++ b/lego/client.go @@ -53,7 +53,15 @@ func NewClient(config *Config) (*Client, error) { solversManager := resolver.NewSolversManager(core) prober := resolver.NewProber(solversManager) - certifier := certificate.NewCertifier(core, prober, certificate.CertifierOptions{KeyType: config.Certificate.KeyType, Timeout: config.Certificate.Timeout, OverallRequestLimit: config.Certificate.OverallRequestLimit}) + + options := certificate.CertifierOptions{ + KeyType: config.Certificate.KeyType, + Timeout: config.Certificate.Timeout, + OverallRequestLimit: config.Certificate.OverallRequestLimit, + DisableCommonName: config.Certificate.DisableCommonName, + } + + certifier := certificate.NewCertifier(core, prober, options) return &Client{ Certificate: certifier, diff --git a/lego/client_config.go b/lego/client_config.go index fdf1a55f8..969135a13 100644 --- a/lego/client_config.go +++ b/lego/client_config.go @@ -64,6 +64,7 @@ type CertificateConfig struct { KeyType certcrypto.KeyType Timeout time.Duration OverallRequestLimit int + DisableCommonName bool } // createDefaultHTTPClient Creates an HTTP client with a reasonable timeout value diff --git a/lego/client_test.go b/lego/client_test.go index 7d2f514dc..63d3b0ad1 100644 --- a/lego/client_test.go +++ b/lego/client_test.go @@ -13,10 +13,9 @@ import ( ) func TestNewClient(t *testing.T) { - _, apiURL := tester.SetupFakeAPI(t) + server := tester.MockACMEServer().BuildHTTPS(t) - keyBits := 32 // small value keeps test fast - key, err := rsa.GenerateKey(rand.Reader, keyBits) + key, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") user := mockUser{ @@ -26,7 +25,8 @@ func TestNewClient(t *testing.T) { } config := NewConfig(user) - config.CADirURL = apiURL + "/dir" + config.CADirURL = server.URL + "/dir" + config.HTTPClient = server.Client() client, err := NewClient(config) require.NoError(t, err, "Could not create client") diff --git a/log/logger.go b/log/logger.go index 48a81fad0..2f700a359 100644 --- a/log/logger.go +++ b/log/logger.go @@ -10,50 +10,50 @@ var Logger StdLogger = log.New(os.Stderr, "", log.LstdFlags) // StdLogger interface for Standard Logger. type StdLogger interface { - Fatal(args ...interface{}) - Fatalln(args ...interface{}) - Fatalf(format string, args ...interface{}) - Print(args ...interface{}) - Println(args ...interface{}) - Printf(format string, args ...interface{}) + Fatal(args ...any) + Fatalln(args ...any) + Fatalf(format string, args ...any) + Print(args ...any) + Println(args ...any) + Printf(format string, args ...any) } // Fatal writes a log entry. // It uses Logger if not nil, otherwise it uses the default log.Logger. -func Fatal(args ...interface{}) { +func Fatal(args ...any) { Logger.Fatal(args...) } // Fatalf writes a log entry. // It uses Logger if not nil, otherwise it uses the default log.Logger. -func Fatalf(format string, args ...interface{}) { +func Fatalf(format string, args ...any) { Logger.Fatalf(format, args...) } // Print writes a log entry. // It uses Logger if not nil, otherwise it uses the default log.Logger. -func Print(args ...interface{}) { +func Print(args ...any) { Logger.Print(args...) } // Println writes a log entry. // It uses Logger if not nil, otherwise it uses the default log.Logger. -func Println(args ...interface{}) { +func Println(args ...any) { Logger.Println(args...) } // Printf writes a log entry. // It uses Logger if not nil, otherwise it uses the default log.Logger. -func Printf(format string, args ...interface{}) { +func Printf(format string, args ...any) { Logger.Printf(format, args...) } // Warnf writes a log entry. -func Warnf(format string, args ...interface{}) { +func Warnf(format string, args ...any) { Printf("[WARN] "+format, args...) } // Infof writes a log entry. -func Infof(format string, args ...interface{}) { +func Infof(format string, args ...any) { Printf("[INFO] "+format, args...) } diff --git a/platform/config/env/env.go b/platform/config/env/env.go index 3fd1e3a1a..33a0d6caa 100644 --- a/platform/config/env/env.go +++ b/platform/config/env/env.go @@ -16,11 +16,13 @@ func Get(names ...string) (map[string]string, error) { values := map[string]string{} var missingEnvVars []string + for _, envVar := range names { value := GetOrFile(envVar) if value == "" { missingEnvVars = append(missingEnvVars, envVar) } + values[envVar] = value } @@ -58,6 +60,7 @@ func GetWithFallback(groups ...[]string) (map[string]string, error) { values := map[string]string{} var missingEnvVars []string + for _, names := range groups { if len(names) == 0 { return nil, errors.New("undefined environment variable names") @@ -68,6 +71,7 @@ func GetWithFallback(groups ...[]string) (map[string]string, error) { missingEnvVars = append(missingEnvVars, envVar) continue } + values[envVar] = value } @@ -107,7 +111,7 @@ func getOneWithFallback(main string, names ...string) (string, string) { // GetOrDefaultString returns the given environment variable value as a string. // Returns the default if the env var cannot be found. -func GetOrDefaultString(envVar string, defaultValue string) string { +func GetOrDefaultString(envVar, defaultValue string) string { return getOrDefault(envVar, defaultValue, ParseString) } @@ -148,6 +152,7 @@ func GetOrFile(envVar string) string { } fileVar := envVar + "_FILE" + fileVarValue := os.Getenv(fileVar) if fileVarValue == "" { return envVarValue @@ -184,3 +189,20 @@ func ParseString(s string) (string, error) { return s, nil } + +// ParsePairs parses a raw string of comma-separated key-value pairs into a map. +// Keys and values are separated by a colon and are trimmed of whitespace. +func ParsePairs(raw string) (map[string]string, error) { + result := make(map[string]string) + + for pair := range strings.SplitSeq(strings.TrimSuffix(raw, ","), ",") { + data := strings.Split(pair, ":") + if len(data) != 2 { + return nil, fmt.Errorf("incorrect pair: %s", pair) + } + + result[strings.TrimSpace(data[0])] = strings.TrimSpace(data[1]) + } + + return result, nil +} diff --git a/platform/config/env/env_test.go b/platform/config/env/env_test.go index 4a3d0a04c..b131d4d91 100644 --- a/platform/config/env/env_test.go +++ b/platform/config/env/env_test.go @@ -367,9 +367,10 @@ func TestGetOrFile_ReadsFiles(t *testing.T) { err = os.Unsetenv(varEnvName) require.NoError(t, err) - file, err := os.CreateTemp("", "lego") + file, err := os.CreateTemp(t.TempDir(), "lego") require.NoError(t, err) - defer os.Remove(file.Name()) + + t.Cleanup(func() { _ = file.Close() }) err = os.WriteFile(file.Name(), []byte("lego_file\n"), 0o644) require.NoError(t, err) @@ -392,9 +393,10 @@ func TestGetOrFile_PrefersEnvVars(t *testing.T) { err = os.Unsetenv(varEnvName) require.NoError(t, err) - file, err := os.CreateTemp("", "lego") + file, err := os.CreateTemp(t.TempDir(), "lego") require.NoError(t, err) - defer os.Remove(file.Name()) + + t.Cleanup(func() { _ = file.Close() }) err = os.WriteFile(file.Name(), []byte("lego_file"), 0o644) require.NoError(t, err) @@ -406,3 +408,77 @@ func TestGetOrFile_PrefersEnvVars(t *testing.T) { assert.Equal(t, "lego_env", value) } + +func TestParsePairs(t *testing.T) { + testCases := []struct { + desc string + value string + expected map[string]string + }{ + { + desc: "one pair", + value: "foo:bar", + expected: map[string]string{"foo": "bar"}, + }, + { + desc: "multiple pairs", + value: "foo:bar,a:b,c:d", + expected: map[string]string{"a": "b", "c": "d", "foo": "bar"}, + }, + { + desc: "multiple pairs with spaces", + value: "foo:bar, a:b , c: d", + expected: map[string]string{"a": "b", "c": "d", "foo": "bar"}, + }, + { + desc: "empty value pair", + value: "foo:", + expected: map[string]string{"foo": ""}, + }, + { + desc: "empty key pair", + value: ":bar", + expected: map[string]string{"": "bar"}, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + pairs, err := ParsePairs(test.value) + require.NoError(t, err) + + assert.Equal(t, test.expected, pairs) + }) + } +} + +func TestParsePairs_error(t *testing.T) { + testCases := []struct { + desc string + value string + }{ + { + desc: "empty value", + value: "", + }, + { + desc: "multiple colons", + value: "foo:bar:bir", + }, + { + desc: "valid pair and multiple colons", + value: "a:b,foo:bar:bir", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + _, err := ParsePairs(test.value) + require.Error(t, err) + }) + } +} diff --git a/platform/tester/api.go b/platform/tester/api.go index 175530f96..8343b487f 100644 --- a/platform/tester/api.go +++ b/platform/tester/api.go @@ -2,63 +2,47 @@ package tester import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" - "testing" "github.com/go-acme/lego/v4/acme" + "github.com/go-acme/lego/v4/platform/tester/servermock" ) -// SetupFakeAPI Minimal stub ACME server for validation. -func SetupFakeAPI(t *testing.T) (*http.ServeMux, string) { - t.Helper() +// MockACMEServer Minimal stub ACME server for validation. +func MockACMEServer() *servermock.Builder[*httptest.Server] { + return servermock.NewBuilder( + func(server *httptest.Server) (*httptest.Server, error) { + return server, nil + }). + Route("GET /dir", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + serverURL := fmt.Sprintf("https://%s", req.Context().Value(http.LocalAddrContextKey)) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/dir", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - err := WriteJSONResponse(w, acme.Directory{ - NewNonceURL: server.URL + "/nonce", - NewAccountURL: server.URL + "/account", - NewOrderURL: server.URL + "/newOrder", - RevokeCertURL: server.URL + "/revokeCert", - KeyChangeURL: server.URL + "/keyChange", - RenewalInfo: server.URL + "/renewalInfo", - }) - - mux.HandleFunc("/nonce", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodHead { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - w.Header().Set("Replay-Nonce", "12345") - w.Header().Set("Retry-After", "0") - }) - - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - return mux, server.URL + servermock.JSONEncode(acme.Directory{ + NewNonceURL: serverURL + "/nonce", + NewAccountURL: serverURL + "/account", + NewOrderURL: serverURL + "/newOrder", + RevokeCertURL: serverURL + "/revokeCert", + KeyChangeURL: serverURL + "/keyChange", + RenewalInfo: serverURL + "/renewalInfo", + }).ServeHTTP(rw, req) + })). + Route("HEAD /nonce", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Header().Set("Replay-Nonce", "12345") + rw.Header().Set("Retry-After", "0") + })) } // WriteJSONResponse marshals the body as JSON and writes it to the response. -func WriteJSONResponse(w http.ResponseWriter, body interface{}) error { +func WriteJSONResponse(w http.ResponseWriter, body any) error { bs, err := json.Marshal(body) if err != nil { return err } w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(bs); err != nil { return err } diff --git a/platform/tester/dnsmock/dnsmock.go b/platform/tester/dnsmock/dnsmock.go new file mode 100644 index 000000000..6cb4f45b8 --- /dev/null +++ b/platform/tester/dnsmock/dnsmock.go @@ -0,0 +1,191 @@ +package dnsmock + +import ( + "fmt" + "math" + "net" + "strings" + "sync" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +const noType uint16 = math.MaxUint16 + +type Option func(*dns.Server) error + +type Builder struct { + // domain -> op -> type + routes map[string]map[int]map[uint16]dns.Handler + + stringToType map[string]uint16 +} + +func NewServer() *Builder { + stringToType := make(map[string]uint16) + for typ, str := range dns.TypeToString { + stringToType[str] = typ + } + + return &Builder{ + routes: make(map[string]map[int]map[uint16]dns.Handler), + stringToType: stringToType, + } +} + +func (b *Builder) Query(pattern string, handler dns.HandlerFunc) *Builder { + route, err := b.route(pattern, dns.OpcodeQuery, handler) + if err != nil { + panic(err.Error()) + } + + return route +} + +func (b *Builder) Update(pattern string, handler dns.HandlerFunc) *Builder { + route, err := b.route(pattern, dns.OpcodeUpdate, handler) + if err != nil { + panic(err.Error()) + } + + return route +} + +func (b *Builder) route(pattern string, op int, handler dns.HandlerFunc) (*Builder, error) { + parts := strings.Fields(pattern) + + domain := parts[0] + + _, ok := dns.IsDomainName(domain) + if !ok { + return nil, fmt.Errorf("%s: invalid domain: %s", dns.OpcodeToString[op], domain) + } + + if _, ok := b.routes[domain]; !ok { + b.routes[domain] = make(map[int]map[uint16]dns.Handler) + } + + if _, ok := b.routes[domain][op]; !ok { + b.routes[domain][op] = make(map[uint16]dns.Handler) + } + + if _, ok := b.routes[domain][op][noType]; ok { + return nil, fmt.Errorf("%s: a global route already exists for the domain: %s", dns.OpcodeToString[op], domain) + } + + switch len(parts) { + case 1: + if len(b.routes[domain][op]) > 0 { + return nil, fmt.Errorf("%s: global route and specific routes cannot be mixed for the same domain: %s", dns.OpcodeToString[op], domain) + } + + b.routes[domain][op][noType] = handler + + return b, nil + + case 2: + raw := parts[1] + + qType, ok := b.stringToType[raw] + if !ok { + return nil, fmt.Errorf("%s: unknown type: %s", dns.OpcodeToString[op], raw) + } + + if _, ok := b.routes[domain][op][qType]; ok { + return nil, fmt.Errorf("%s: duplicate route: %s", dns.OpcodeToString[op], pattern) + } + + b.routes[domain][op][qType] = handler + + return b, nil + + default: + return nil, fmt.Errorf("%s: invalid pattern: %s", dns.OpcodeToString[op], pattern) + } +} + +func (b *Builder) Build(t *testing.T, options ...Option) net.Addr { + t.Helper() + + mux := dns.NewServeMux() + + server := &dns.Server{ + Addr: "127.0.0.1:0", + Net: "udp", + ReadTimeout: time.Hour, + WriteTimeout: time.Hour, + Handler: mux, + MsgAcceptFunc: func(dh dns.Header) dns.MsgAcceptAction { + // bypass defaultMsgAcceptFunc to allow dynamic update (https://github.com/miekg/dns/pull/830) + return dns.MsgAccept + }, + } + + for _, option := range options { + require.NoError(t, option(server)) + } + + for pattern, ops := range b.routes { + mux.HandleFunc(pattern, func(w dns.ResponseWriter, req *dns.Msg) { + mTypes, ok := ops[req.Opcode] + if !ok { + _ = w.WriteMsg(new(dns.Msg).SetRcode(req, dns.RcodeNotImplemented)) + + return + } + + if h, found := mTypes[noType]; found { + h.ServeDNS(w, req) + + return + } + + // For safety but it doesn't happen. + if len(req.Question) == 0 { + _ = w.WriteMsg(new(dns.Msg).SetRcode(req, dns.RcodeRefused)) + + return + } + + // For safety but it doesn't happen. + if req.Question[0].Qclass != dns.ClassINET { + _ = w.WriteMsg(new(dns.Msg).SetRcode(req, dns.RcodeRefused)) + + return + } + + // Works only for [Query]. + h, ok := mTypes[req.Question[0].Qtype] + if !ok { + _ = w.WriteMsg(new(dns.Msg).SetRcode(req, dns.RcodeNotImplemented)) + + return + } + + h.ServeDNS(w, req) + }) + } + + t.Cleanup(func() { + _ = server.Shutdown() + }) + + waitLock := sync.Mutex{} + waitLock.Lock() + + server.NotifyStartedFunc = waitLock.Unlock + + go func() { + err := server.ListenAndServe() + if err != nil { + t.Log(err) + } + }() + + waitLock.Lock() + + return server.PacketConn.LocalAddr() +} diff --git a/platform/tester/dnsmock/dnsmock_test.go b/platform/tester/dnsmock/dnsmock_test.go new file mode 100644 index 000000000..77a67a402 --- /dev/null +++ b/platform/tester/dnsmock/dnsmock_test.go @@ -0,0 +1,240 @@ +package dnsmock + +import ( + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServer_Query_matchType(t *testing.T) { + addr := NewServer(). + Query("example.com. SOA", Noop). + Build(t) + + client := &dns.Client{Timeout: 1 * time.Second} + + m := new(dns.Msg).SetQuestion("example.com.", dns.TypeSOA) + + r, _, err := client.Exchange(m, addr.String()) + require.NoError(t, err) + + require.Equalf(t, dns.RcodeSuccess, r.Rcode, + "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode]) + assert.Equal(t, m.Question, r.Question) +} + +func TestServer_Query_noType(t *testing.T) { + addr := NewServer(). + Query("example.com.", Noop). + Build(t) + + client := &dns.Client{Timeout: 1 * time.Second} + + m := new(dns.Msg).SetQuestion("example.com.", dns.TypeSOA) + + r, _, err := client.Exchange(m, addr.String()) + require.NoError(t, err) + + require.Equalf(t, dns.RcodeSuccess, r.Rcode, + "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode]) + assert.Equal(t, m.Question, r.Question) +} + +func TestServer_Query_noMatch_domain(t *testing.T) { + addr := NewServer(). + Query("example.com. SOA", Noop). + Build(t) + + client := &dns.Client{Timeout: 1 * time.Second} + + m := new(dns.Msg).SetQuestion("example.org.", dns.TypeSOA) + + r, _, err := client.Exchange(m, addr.String()) + require.NoError(t, err) + + require.Equalf(t, dns.RcodeRefused, r.Rcode, + "expected %s, got %s", dns.RcodeToString[dns.RcodeRefused], dns.RcodeToString[r.Rcode]) + assert.Equal(t, m.Question, r.Question) +} + +func TestServer_Query_noMatch_type(t *testing.T) { + addr := NewServer(). + Query("example.com. SOA", Noop). + Build(t) + + client := &dns.Client{Timeout: 1 * time.Second} + + m := new(dns.Msg).SetQuestion("example.com.", dns.TypeTXT) + + r, _, err := client.Exchange(m, addr.String()) + require.NoError(t, err) + + require.Equalf(t, dns.RcodeNotImplemented, r.Rcode, + "expected %s, got %s", dns.RcodeToString[dns.RcodeNotImplemented], dns.RcodeToString[r.Rcode]) + assert.Equal(t, m.Question, r.Question) +} + +func TestServer_Query_noMatch_opType(t *testing.T) { + addr := NewServer(). + Query("example.com.", Noop). + Build(t) + + client := &dns.Client{Timeout: 1 * time.Second} + + m := new(dns.Msg).SetUpdate("example.com.") + m.Insert([]dns.RR{ + &dns.TXT{ + Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1}, + Txt: []string{"foo"}, + }, + }) + + r, _, err := client.Exchange(m, addr.String()) + require.NoError(t, err) + + require.Equalf(t, dns.RcodeNotImplemented, r.Rcode, + "expected %s, got %s", dns.RcodeToString[dns.RcodeNotImplemented], dns.RcodeToString[r.Rcode]) + assert.Equal(t, m.Question, r.Question) +} + +func TestServer_Query_unknownType(t *testing.T) { + assert.PanicsWithValue(t, "QUERY: unknown type: ABC", func() { + NewServer(). + Query("example.com. ABC", Noop). + Build(t) + }) +} + +func TestServer_Query_duplicate(t *testing.T) { + assert.PanicsWithValue(t, "QUERY: duplicate route: example.com. SOA", func() { + NewServer(). + Query("example.com. SOA", Noop). + Query("example.com. SOA", Noop). + Build(t) + }) +} + +func TestServer_Query_duplicateGlobal(t *testing.T) { + assert.PanicsWithValue(t, "QUERY: a global route already exists for the domain: example.com.", func() { + NewServer(). + Query("example.com.", Noop). + Query("example.com.", Noop). + Build(t) + }) +} + +func TestServer_Query_mixed(t *testing.T) { + assert.PanicsWithValue(t, "QUERY: global route and specific routes cannot be mixed for the same domain: example.com.", func() { + NewServer(). + Query("example.com. SOA", Noop). + Query("example.com.", Noop). + Build(t) + }) +} + +func TestServer_Query_invalidDomain(t *testing.T) { + assert.PanicsWithValue(t, "QUERY: invalid domain: .example.com.", func() { + NewServer(). + Query(".example.com. SOA", Noop). + Build(t) + }) +} + +func TestServer_Query_invalidPattern(t *testing.T) { + assert.PanicsWithValue(t, "QUERY: invalid pattern: example.com. SOA 13", func() { + NewServer(). + Query("example.com. SOA 13", Noop). + Build(t) + }) +} + +func TestServer_Update(t *testing.T) { + addr := NewServer(). + Update("example.com.", Noop). + Build(t) + + client := &dns.Client{Timeout: 1 * time.Second} + + m := new(dns.Msg).SetUpdate("example.com.") + m.Insert([]dns.RR{ + &dns.TXT{ + Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1}, + Txt: []string{"foo"}, + }, + }) + + r, _, err := client.Exchange(m, addr.String()) + require.NoError(t, err) + + require.Equalf(t, dns.RcodeSuccess, r.Rcode, + "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode]) + assert.Equal(t, m.Question, r.Question) +} + +func TestServer_Update_noMatch_domain(t *testing.T) { + addr := NewServer(). + Update("example.com.", Noop). + Build(t) + + client := &dns.Client{Timeout: 1 * time.Second} + + m := new(dns.Msg).SetUpdate("example.org.") + m.Insert([]dns.RR{ + &dns.TXT{ + Hdr: dns.RR_Header{Name: "example.org.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1}, + Txt: []string{"foo"}, + }, + }) + + r, _, err := client.Exchange(m, addr.String()) + require.NoError(t, err) + + require.Equalf(t, dns.RcodeRefused, r.Rcode, + "expected %s, got %s", dns.RcodeToString[dns.RcodeRefused], dns.RcodeToString[r.Rcode]) + assert.Equal(t, m.Question, r.Question) +} + +func TestServer_Update_noMatch_opType(t *testing.T) { + addr := NewServer(). + Update("example.com.", Noop). + Build(t) + + client := &dns.Client{Timeout: 1 * time.Second} + + m := new(dns.Msg).SetQuestion("example.com.", dns.TypeTXT) + + r, _, err := client.Exchange(m, addr.String()) + require.NoError(t, err) + + require.Equalf(t, dns.RcodeNotImplemented, r.Rcode, + "expected %s, got %s", dns.RcodeToString[dns.RcodeNotImplemented], dns.RcodeToString[r.Rcode]) + assert.Equal(t, m.Question, r.Question) +} + +func TestServer_Update_duplicate(t *testing.T) { + assert.PanicsWithValue(t, "UPDATE: a global route already exists for the domain: example.com.", func() { + NewServer(). + Update("example.com.", Noop). + Update("example.com.", Noop). + Build(t) + }) +} + +func TestServer_Update_invalidDomain(t *testing.T) { + assert.PanicsWithValue(t, "UPDATE: invalid domain: .example.com.", func() { + NewServer(). + Update(".example.com.", Noop). + Build(t) + }) +} + +func TestServer_Update_invalidPattern(t *testing.T) { + assert.PanicsWithValue(t, "UPDATE: invalid pattern: example.com. SOA 13", func() { + NewServer(). + Update("example.com. SOA 13", Noop). + Build(t) + }) +} diff --git a/platform/tester/dnsmock/handlers.go b/platform/tester/dnsmock/handlers.go new file mode 100644 index 000000000..e1b047318 --- /dev/null +++ b/platform/tester/dnsmock/handlers.go @@ -0,0 +1,76 @@ +package dnsmock + +import ( + "fmt" + + "github.com/miekg/dns" +) + +func DumpRequest() dns.HandlerFunc { + return func(w dns.ResponseWriter, req *dns.Msg) { + fmt.Println(req) + + Noop(w, req) + } +} + +func SOA(name string) dns.HandlerFunc { + return func(w dns.ResponseWriter, req *dns.Msg) { + if name == "" { + name = req.Question[0].Name + } + + // Handle TLD + base := name + if dns.CountLabel(req.Question[0].Name) == 1 { + base = "nic." + req.Question[0].Name + } + + answer := &dns.SOA{ + Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 120}, + Ns: "ns1." + base, + Mbox: "admin." + base, + Serial: 2016022801, + Refresh: 28800, + Retry: 7200, + Expire: 2419200, + Minttl: 1200, + } + + Answer(answer)(w, req) + } +} + +func CNAME(target string) dns.HandlerFunc { + return func(w dns.ResponseWriter, req *dns.Msg) { + answer := &dns.CNAME{ + Hdr: dns.RR_Header{Name: req.Question[0].Name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 1}, + Target: dns.Fqdn(target), + } + + Answer(answer)(w, req) + } +} + +func Noop(w dns.ResponseWriter, req *dns.Msg) { + _ = w.WriteMsg(new(dns.Msg).SetReply(req)) +} + +func Error(rcode int) dns.HandlerFunc { + return func(w dns.ResponseWriter, req *dns.Msg) { + _ = w.WriteMsg(new(dns.Msg).SetRcode(req, rcode)) + } +} + +func Answer(answer ...dns.RR) func(w dns.ResponseWriter, req *dns.Msg) { + return func(w dns.ResponseWriter, req *dns.Msg) { + m := new(dns.Msg).SetReply(req) + + m.Answer = answer + + err := w.WriteMsg(m) + if err != nil { + panic(err.Error()) + } + } +} diff --git a/platform/tester/dnsmock/handlers_test.go b/platform/tester/dnsmock/handlers_test.go new file mode 100644 index 000000000..13cdc0e2d --- /dev/null +++ b/platform/tester/dnsmock/handlers_test.go @@ -0,0 +1,156 @@ +package dnsmock + +import ( + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSOA_self(t *testing.T) { + addr := NewServer(). + Query("example.com. SOA", SOA("")). + Build(t) + + client := &dns.Client{Timeout: 1 * time.Second} + + m := new(dns.Msg).SetQuestion("example.com.", dns.TypeSOA) + + r, _, err := client.Exchange(m, addr.String()) + require.NoError(t, err) + + expectedSOA := []dns.RR{&dns.SOA{ + Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 120, Rdlength: 56}, + Ns: "ns1.example.com.", + Mbox: "admin.example.com.", + Serial: 2016022801, + Refresh: 28800, + Retry: 7200, + Expire: 2419200, + Minttl: 1200, + }} + + require.Equal(t, dns.RcodeSuccess, r.Rcode) + assert.Equal(t, expectedSOA, r.Answer) + assert.Equal(t, m.Question, r.Question) +} + +func TestSOA_differentDomain(t *testing.T) { + addr := NewServer(). + Query("example.com. SOA", SOA("example.org.")). + Build(t) + + client := &dns.Client{Timeout: 1 * time.Second} + + m := new(dns.Msg).SetQuestion("example.com.", dns.TypeSOA) + + r, _, err := client.Exchange(m, addr.String()) + require.NoError(t, err) + + require.Equalf(t, dns.RcodeSuccess, r.Rcode, + "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode]) + + expectedSOA := []dns.RR{&dns.SOA{ + Hdr: dns.RR_Header{Name: "example.org.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 120, Rdlength: 56}, + Ns: "ns1.example.org.", + Mbox: "admin.example.org.", + Serial: 2016022801, + Refresh: 28800, + Retry: 7200, + Expire: 2419200, + Minttl: 1200, + }} + + assert.Equal(t, expectedSOA, r.Answer) + assert.Equal(t, m.Question, r.Question) +} + +func TestSOA_tld(t *testing.T) { + addr := NewServer(). + Query("com. SOA", SOA("")). + Build(t) + + client := &dns.Client{Timeout: 1 * time.Second} + + m := new(dns.Msg).SetQuestion("com.", dns.TypeSOA) + + r, _, err := client.Exchange(m, addr.String()) + require.NoError(t, err) + + require.Equalf(t, dns.RcodeSuccess, r.Rcode, + "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode]) + + expectedSOA := []dns.RR{&dns.SOA{ + Hdr: dns.RR_Header{Name: "com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 120, Rdlength: 48}, + Ns: "ns1.nic.com.", + Mbox: "admin.nic.com.", + Serial: 2016022801, + Refresh: 28800, + Retry: 7200, + Expire: 2419200, + Minttl: 1200, + }} + + assert.Equal(t, expectedSOA, r.Answer) + assert.Equal(t, m.Question, r.Question) +} + +func TestCNAME(t *testing.T) { + addr := NewServer(). + Query("example.com. CNAME", CNAME("example.org.")). + Build(t) + + client := &dns.Client{Timeout: 1 * time.Second} + + m := new(dns.Msg).SetQuestion("example.com.", dns.TypeCNAME) + + r, _, err := client.Exchange(m, addr.String()) + require.NoError(t, err) + + require.Equalf(t, dns.RcodeSuccess, r.Rcode, + "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode]) + + expectedCNAME := []dns.RR{&dns.CNAME{ + Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 1, Rdlength: 13}, + Target: "example.org.", + }} + + assert.Equal(t, expectedCNAME, r.Answer) + assert.Equal(t, m.Question, r.Question) +} + +func TestNoop(t *testing.T) { + addr := NewServer(). + Query("example.com. CNAME", Noop). + Build(t) + + client := &dns.Client{Timeout: 1 * time.Second} + + m := new(dns.Msg).SetQuestion("example.com.", dns.TypeCNAME) + + r, _, err := client.Exchange(m, addr.String()) + require.NoError(t, err) + + require.Equalf(t, dns.RcodeSuccess, r.Rcode, + "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode]) + assert.Equal(t, m.Question, r.Question) +} + +func TestError(t *testing.T) { + addr := NewServer(). + Query("example.com. CNAME", Error(dns.RcodeNameError)). + Build(t) + + client := &dns.Client{Timeout: 1 * time.Second} + + m := new(dns.Msg).SetQuestion("example.com.", dns.TypeCNAME) + + r, _, err := client.Exchange(m, addr.String()) + require.NoError(t, err) + + require.Equalf(t, dns.RcodeNameError, r.Rcode, + "expected %s, got %s", dns.RcodeToString[dns.RcodeNameError], dns.RcodeToString[r.Rcode]) + assert.Equal(t, m.Question, r.Question) +} diff --git a/platform/tester/env.go b/platform/tester/env.go index 26788be3b..a12c32ef8 100644 --- a/platform/tester/env.go +++ b/platform/tester/env.go @@ -21,6 +21,7 @@ type EnvTest struct { // NewEnvTest Creates an EnvTest. func NewEnvTest(keys ...string) *EnvTest { values := make(map[string]string) + for _, key := range keys { value := os.Getenv(key) if value != "" { @@ -39,6 +40,7 @@ func NewEnvTest(keys ...string) *EnvTest { func (e *EnvTest) WithDomain(key string) *EnvTest { e.domainKey = key e.domain = os.Getenv(key) + return e } diff --git a/platform/tester/env_test.go b/platform/tester/env_test.go index 25748f8ff..4d9e4e7d1 100644 --- a/platform/tester/env_test.go +++ b/platform/tester/env_test.go @@ -18,6 +18,7 @@ const ( func TestMain(m *testing.M) { exitCode := m.Run() + clearEnv() os.Exit(exitCode) } @@ -39,6 +40,7 @@ func clearEnv() { os.Unsetenv(strings.Split(key, "=")[0]) } } + os.Unsetenv("EXTRA_LEGO_TEST") } @@ -62,7 +64,7 @@ func TestEnvTest(t *testing.T) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetDomain()) + assert.Empty(t, envTest.GetDomain()) }, }, { @@ -75,9 +77,9 @@ func TestEnvTest(t *testing.T) { }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.False(t, envTest.IsLiveTest()) - assert.Equal(t, "", envTest.GetValue(envVar01)) + assert.Empty(t, envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetDomain()) + assert.Empty(t, envTest.GetDomain()) }, }, { @@ -94,7 +96,7 @@ func TestEnvTest(t *testing.T) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetValue(envVarDomain)) + assert.Empty(t, envTest.GetValue(envVarDomain)) assert.Equal(t, "D", envTest.GetDomain()) }, }, @@ -110,8 +112,8 @@ func TestEnvTest(t *testing.T) { expected: func(t *testing.T, envTest *tester.EnvTest) { assert.False(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) - assert.Equal(t, "", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetValue(envVarDomain)) + assert.Empty(t, envTest.GetValue(envVar02)) + assert.Empty(t, envTest.GetValue(envVarDomain)) assert.Equal(t, "D", envTest.GetDomain()) }, }, @@ -128,8 +130,8 @@ func TestEnvTest(t *testing.T) { assert.False(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetValue(envVarDomain)) - assert.Equal(t, "", envTest.GetDomain()) + assert.Empty(t, envTest.GetValue(envVarDomain)) + assert.Empty(t, envTest.GetDomain()) }, }, { @@ -145,7 +147,7 @@ func TestEnvTest(t *testing.T) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetDomain()) + assert.Empty(t, envTest.GetDomain()) }, }, { @@ -161,7 +163,7 @@ func TestEnvTest(t *testing.T) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetDomain()) + assert.Empty(t, envTest.GetDomain()) }, }, { @@ -174,9 +176,9 @@ func TestEnvTest(t *testing.T) { }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.True(t, envTest.IsLiveTest()) - assert.Equal(t, "", envTest.GetValue(envVar01)) + assert.Empty(t, envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetDomain()) + assert.Empty(t, envTest.GetDomain()) }, }, { @@ -190,8 +192,8 @@ func TestEnvTest(t *testing.T) { expected: func(t *testing.T, envTest *tester.EnvTest) { assert.False(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) - assert.Equal(t, "", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetDomain()) + assert.Empty(t, envTest.GetValue(envVar02)) + assert.Empty(t, envTest.GetDomain()) }, }, { @@ -210,7 +212,7 @@ func TestEnvTest(t *testing.T) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetValue(envVarDomain)) + assert.Empty(t, envTest.GetValue(envVarDomain)) assert.Equal(t, "D", envTest.GetDomain()) }, }, @@ -229,8 +231,8 @@ func TestEnvTest(t *testing.T) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetValue(envVarDomain)) - assert.Equal(t, "", envTest.GetDomain()) + assert.Empty(t, envTest.GetValue(envVarDomain)) + assert.Empty(t, envTest.GetDomain()) }, }, { @@ -247,7 +249,7 @@ func TestEnvTest(t *testing.T) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetDomain()) + assert.Empty(t, envTest.GetDomain()) }, }, { @@ -264,7 +266,7 @@ func TestEnvTest(t *testing.T) { assert.False(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetDomain()) + assert.Empty(t, envTest.GetDomain()) }, }, { @@ -282,7 +284,7 @@ func TestEnvTest(t *testing.T) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetDomain()) + assert.Empty(t, envTest.GetDomain()) }, }, { @@ -300,7 +302,7 @@ func TestEnvTest(t *testing.T) { assert.False(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetDomain()) + assert.Empty(t, envTest.GetDomain()) }, }, { @@ -316,8 +318,8 @@ func TestEnvTest(t *testing.T) { expected: func(t *testing.T, envTest *tester.EnvTest) { assert.False(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) - assert.Equal(t, "", envTest.GetValue(envVar02)) - assert.Equal(t, "", envTest.GetDomain()) + assert.Empty(t, envTest.GetValue(envVar02)) + assert.Empty(t, envTest.GetDomain()) }, }, } @@ -325,6 +327,7 @@ func TestEnvTest(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer clearEnv() + applyEnv(test.envVars) envTest := test.envTestSetup() @@ -357,7 +360,7 @@ func TestEnvTest_ClearEnv(t *testing.T) { envTest.ClearEnv() - assert.Equal(t, "", os.Getenv(envVar01)) - assert.Equal(t, "", os.Getenv(envVar02)) + assert.Empty(t, os.Getenv(envVar01)) + assert.Empty(t, os.Getenv(envVar02)) assert.Equal(t, "X", os.Getenv("EXTRA_LEGO_TEST")) } diff --git a/platform/tester/servermock/builder.go b/platform/tester/servermock/builder.go new file mode 100644 index 000000000..b5a9d909b --- /dev/null +++ b/platform/tester/servermock/builder.go @@ -0,0 +1,84 @@ +package servermock + +import ( + "net/http" + "net/http/httptest" + "slices" + "testing" + + "github.com/stretchr/testify/require" +) + +// Link represents a middleware interface, enabling middleware chaining. +type Link interface { + Bind(next http.Handler) http.Handler +} + +// LinkFunc defines a function type [Link]. +type LinkFunc func(next http.Handler) http.Handler + +func (f LinkFunc) Bind(next http.Handler) http.Handler { + return f(next) +} + +// ClientBuilder defines a function type for creating a client of type T based on a httptest.Server instance. +type ClientBuilder[T any] func(server *httptest.Server) (T, error) + +// Builder is a type that facilitates the construction of testable HTTP clients and server. +// It allows defining routes, attaching middleware, and creating custom HTTP clients. +type Builder[T any] struct { + mux *http.ServeMux + chain []Link + + clientBuilder ClientBuilder[T] +} + +func NewBuilder[T any](clientBuilder ClientBuilder[T], chain ...Link) *Builder[T] { + return &Builder[T]{ + mux: http.NewServeMux(), + chain: chain, + clientBuilder: clientBuilder, + } +} + +func (b *Builder[T]) Route(pattern string, handler http.Handler, chain ...Link) *Builder[T] { + if handler == nil { + handler = Noop() + } + + for _, link := range slices.Backward(b.chain) { + handler = link.Bind(handler) + } + + for _, link := range slices.Backward(chain) { + handler = link.Bind(handler) + } + + b.mux.Handle(pattern, handler) + + return b +} + +func (b *Builder[T]) Build(t *testing.T) T { + t.Helper() + + server := httptest.NewServer(b.mux) + t.Cleanup(server.Close) + + client, err := b.clientBuilder(server) + require.NoError(t, err) + + return client +} + +func (b *Builder[T]) BuildHTTPS(t *testing.T) T { + t.Helper() + + server := httptest.NewTLSServer(b.mux) + t.Cleanup(server.Close) + + client, err := b.clientBuilder(server) + require.NoError(t, err) + + return client +} diff --git a/platform/tester/servermock/handler_dump.go b/platform/tester/servermock/handler_dump.go new file mode 100644 index 000000000..83f902980 --- /dev/null +++ b/platform/tester/servermock/handler_dump.go @@ -0,0 +1,20 @@ +package servermock + +import ( + "fmt" + "net/http" + "net/http/httputil" +) + +// DumpRequest logs the full HTTP request to the console, including the body if present. +func DumpRequest() http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + dump, err := httputil.DumpRequest(req, true) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + fmt.Println(string(dump)) + } +} diff --git a/platform/tester/servermock/handler_file.go b/platform/tester/servermock/handler_file.go new file mode 100644 index 000000000..c5a9b33e1 --- /dev/null +++ b/platform/tester/servermock/handler_file.go @@ -0,0 +1,84 @@ +package servermock + +import ( + "io" + "net/http" + "os" + "path/filepath" + "slices" +) + +// ResponseFromFileHandler handles HTTP responses using the content of a file. +type ResponseFromFileHandler struct { + statusCode int + headers http.Header + filename string +} + +// ResponseFromFile creates a [ResponseFromFileHandler] using a filename. +func ResponseFromFile(filename string) *ResponseFromFileHandler { + return &ResponseFromFileHandler{ + statusCode: http.StatusOK, + headers: http.Header{}, + filename: filename, + } +} + +// ResponseFromFixture creates a [ResponseFromFileHandler] using a filename from the `fixtures` directory. +func ResponseFromFixture(filename string) *ResponseFromFileHandler { + return ResponseFromFile(filepath.Join("fixtures", filename)) +} + +// ResponseFromInternal creates a [ResponseFromFileHandler] using a filename from the `internal/fixtures` directory. +func ResponseFromInternal(filename string) *ResponseFromFileHandler { + return ResponseFromFile(filepath.Join("internal", "fixtures", filename)) +} + +func (h *ResponseFromFileHandler) ServeHTTP(rw http.ResponseWriter, _ *http.Request) { + for k, values := range h.headers { + for _, v := range values { + rw.Header().Add(k, v) + } + } + + if h.filename == "" { + rw.WriteHeader(h.statusCode) + return + } + + if filepath.Ext(h.filename) == ".json" { + rw.Header().Set(contentTypeHeader, applicationJSONMimeType) + } + + file, err := os.Open(h.filename) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + defer func() { _ = file.Close() }() + + rw.WriteHeader(h.statusCode) + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } +} + +func (h *ResponseFromFileHandler) WithStatusCode(status int) *ResponseFromFileHandler { + if h.statusCode >= http.StatusContinue { + h.statusCode = status + } + + return h +} + +func (h *ResponseFromFileHandler) WithHeader(name, value string, values ...string) *ResponseFromFileHandler { + for _, v := range slices.Concat([]string{value}, values) { + h.headers.Add(name, v) + } + + return h +} diff --git a/platform/tester/servermock/handler_json.go b/platform/tester/servermock/handler_json.go new file mode 100644 index 000000000..f1c2aa9ce --- /dev/null +++ b/platform/tester/servermock/handler_json.go @@ -0,0 +1,39 @@ +package servermock + +import ( + "encoding/json" + "net/http" +) + +// JSONEncodeHandler is a handler that encodes data into JSON and writes it to an HTTP response. +type JSONEncodeHandler struct { + data any + statusCode int +} + +func JSONEncode(data any) *JSONEncodeHandler { + return &JSONEncodeHandler{ + data: data, + statusCode: http.StatusOK, + } +} + +func (h *JSONEncodeHandler) ServeHTTP(rw http.ResponseWriter, _ *http.Request) { + rw.Header().Set(contentTypeHeader, applicationJSONMimeType) + + rw.WriteHeader(h.statusCode) + + err := json.NewEncoder(rw).Encode(h.data) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } +} + +func (h *JSONEncodeHandler) WithStatusCode(status int) *JSONEncodeHandler { + if h.statusCode >= http.StatusContinue { + h.statusCode = status + } + + return h +} diff --git a/platform/tester/servermock/handler_noop.go b/platform/tester/servermock/handler_noop.go new file mode 100644 index 000000000..6df5164e6 --- /dev/null +++ b/platform/tester/servermock/handler_noop.go @@ -0,0 +1,45 @@ +package servermock + +import ( + "net/http" + "slices" +) + +// NoopHandler is a simple HTTP handler that responds without processing requests. +type NoopHandler struct { + statusCode int + headers http.Header +} + +func Noop() *NoopHandler { + return &NoopHandler{ + statusCode: http.StatusOK, + headers: http.Header{}, + } +} + +func (h *NoopHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + for k, values := range h.headers { + for _, v := range values { + rw.Header().Add(k, v) + } + } + + rw.WriteHeader(h.statusCode) +} + +func (h *NoopHandler) WithStatusCode(status int) *NoopHandler { + if h.statusCode >= http.StatusContinue { + h.statusCode = status + } + + return h +} + +func (h *NoopHandler) WithHeader(name, value string, values ...string) *NoopHandler { + for _, v := range slices.Concat([]string{value}, values) { + h.headers.Add(name, v) + } + + return h +} diff --git a/platform/tester/servermock/handler_raw.go b/platform/tester/servermock/handler_raw.go new file mode 100644 index 000000000..d7c68f396 --- /dev/null +++ b/platform/tester/servermock/handler_raw.go @@ -0,0 +1,61 @@ +package servermock + +import ( + "net/http" + "slices" +) + +// RawResponseHandler is a custom HTTP handler that serves raw response data. +type RawResponseHandler struct { + statusCode int + headers http.Header + data []byte +} + +func RawResponse(data []byte) *RawResponseHandler { + return &RawResponseHandler{ + statusCode: http.StatusOK, + headers: http.Header{}, + data: data, + } +} + +func RawStringResponse(data string) *RawResponseHandler { + return RawResponse([]byte(data)) +} + +func (h *RawResponseHandler) ServeHTTP(rw http.ResponseWriter, _ *http.Request) { + for k, values := range h.headers { + for _, v := range values { + rw.Header().Add(k, v) + } + } + + rw.WriteHeader(h.statusCode) + + if len(h.data) == 0 { + return + } + + _, err := rw.Write(h.data) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } +} + +func (h *RawResponseHandler) WithStatusCode(status int) *RawResponseHandler { + if h.statusCode >= http.StatusContinue { + h.statusCode = status + } + + return h +} + +func (h *RawResponseHandler) WithHeader(name, value string, values ...string) *RawResponseHandler { + for _, v := range slices.Concat([]string{value}, values) { + h.headers.Add(name, v) + } + + return h +} diff --git a/platform/tester/servermock/link_form.go b/platform/tester/servermock/link_form.go new file mode 100644 index 000000000..581e27d66 --- /dev/null +++ b/platform/tester/servermock/link_form.go @@ -0,0 +1,97 @@ +package servermock + +import ( + "fmt" + "net/http" + "net/url" + "regexp" + "slices" +) + +// FormLink is a type used for validating and processing form data in HTTP requests. +// It supports strict validation, predefined values, and regex-based checks to ensure form compliance. +type FormLink struct { + values url.Values + regexes map[string]*regexp.Regexp + strict bool + usePostForm bool + statusCode int +} + +func CheckForm() *FormLink { + return &FormLink{ + values: url.Values{}, + regexes: map[string]*regexp.Regexp{}, + statusCode: http.StatusBadRequest, + } +} + +func (l *FormLink) Bind(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + err := req.ParseForm() + if err != nil { + http.Error(rw, err.Error(), l.statusCode) + return + } + + form := req.Form + if l.usePostForm { + form = req.PostForm + } + + if l.strict { + if len(form) != len(l.values)+len(l.regexes) { + msg := fmt.Sprintf("invalid query parameters, got %v, want %v", req.Form, l.values) + http.Error(rw, msg, l.statusCode) + + return + } + } + + for k, v := range l.values { + value := form[k] + if !slices.Equal(v, value) { + msg := fmt.Sprintf("invalid %q form value, got %q, want %q", k, value, v) + http.Error(rw, msg, l.statusCode) + + return + } + } + + for k, exp := range l.regexes { + value := form.Get(k) + if !exp.MatchString(value) { + msg := fmt.Sprintf("invalid %q form value, %q doesn't match to %q", k, value, exp) + http.Error(rw, msg, l.statusCode) + + return + } + } + + next.ServeHTTP(rw, req) + }) +} + +func (l *FormLink) Strict() *FormLink { + l.strict = true + + return l +} + +func (l *FormLink) UsePostForm() *FormLink { + l.usePostForm = true + + return l +} + +func (l *FormLink) With(name, value string) *FormLink { + l.values.Set(name, value) + + return l +} + +func (l *FormLink) WithRegexp(name, exp string) *FormLink { + l.regexes[name] = regexp.MustCompile(exp) + + return l +} diff --git a/platform/tester/servermock/link_headers.go b/platform/tester/servermock/link_headers.go new file mode 100644 index 000000000..0ca519958 --- /dev/null +++ b/platform/tester/servermock/link_headers.go @@ -0,0 +1,178 @@ +package servermock + +import ( + "fmt" + "net/http" + "regexp" + "slices" +) + +const ( + authorizationHeader = "Authorization" + contentTypeHeader = "Content-Type" + acceptHeader = "Accept" +) + +const ( + applicationJSONMimeType = "application/json" + applicationFormMimeType = "application/x-www-form-urlencoded" +) + +type basicAuth struct { + username, password string +} + +// HeaderLink validates HTTP request headers. +type HeaderLink struct { + values http.Header + regexes map[string]*regexp.Regexp + json bool + basicAuth *basicAuth + statusCode int +} + +func CheckHeader() *HeaderLink { + return &HeaderLink{ + values: http.Header{}, + regexes: map[string]*regexp.Regexp{}, + statusCode: http.StatusBadRequest, + } +} + +func (l *HeaderLink) Bind(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + for k, v := range l.values { + err := checkHeader(req, k, v) + if err != nil { + http.Error(rw, err.Error(), l.statusCode) + return + } + } + + for k, exp := range l.regexes { + value := req.Header.Get(k) + + if !exp.MatchString(value) { + msg := fmt.Sprintf("invalid %q header value, %q doesn't match to %q", k, value, exp) + http.Error(rw, msg, l.statusCode) + + return + } + } + + if l.json && !l.checkJSONHeaders(rw, req) { + return + } + + if l.basicAuth != nil && !l.checkBasicAuth(rw, req) { + return + } + + next.ServeHTTP(rw, req) + }) +} + +func (l *HeaderLink) With(name, value string, values ...string) *HeaderLink { + for _, v := range slices.Concat([]string{value}, values) { + l.values.Add(name, v) + } + + return l +} + +func (l *HeaderLink) WithRegexp(name, exp string) *HeaderLink { + l.regexes[name] = regexp.MustCompile(exp) + + return l +} + +func (l *HeaderLink) WithJSONHeaders() *HeaderLink { + l.json = true + + return l +} + +func (l *HeaderLink) WithContentTypeFromURLEncoded() *HeaderLink { + l.values.Set(contentTypeHeader, applicationFormMimeType) + + return l +} + +func (l *HeaderLink) WithContentType(value string) *HeaderLink { + l.values.Set(contentTypeHeader, value) + + return l +} + +func (l *HeaderLink) WithAccept(value string) *HeaderLink { + l.values.Set(acceptHeader, value) + + return l +} + +func (l *HeaderLink) WithAuthorization(value string) *HeaderLink { + l.values.Set(authorizationHeader, value) + + return l +} + +func (l *HeaderLink) WithStatusCode(status int) *HeaderLink { + if l.statusCode >= http.StatusContinue { + l.statusCode = status + } + + return l +} + +func (l *HeaderLink) WithBasicAuth(username, password string) *HeaderLink { + l.basicAuth = &basicAuth{username: username, password: password} + + return l +} + +func (l *HeaderLink) checkBasicAuth(rw http.ResponseWriter, req *http.Request) bool { + usr, pwd, ok := req.BasicAuth() + if !ok { + http.Error(rw, "missing Basic auth", l.statusCode) + + return false + } + + if usr != l.basicAuth.username || pwd != l.basicAuth.password { + msg := fmt.Sprintf("invalid credentials: got [username: %q, password: %q], want [username: %q, password: %q]", + usr, pwd, l.basicAuth.username, l.basicAuth.password) + http.Error(rw, msg, l.statusCode) + + return false + } + + return true +} + +func (l *HeaderLink) checkJSONHeaders(rw http.ResponseWriter, req *http.Request) bool { + err := checkHeader(req, acceptHeader, []string{applicationJSONMimeType}) + if err != nil { + http.Error(rw, err.Error(), l.statusCode) + + return false + } + + if req.ContentLength > 0 { + err = checkHeader(req, contentTypeHeader, []string{applicationJSONMimeType}) + if err != nil { + http.Error(rw, err.Error(), l.statusCode) + + return false + } + } + + return true +} + +func checkHeader(req *http.Request, k string, v []string) error { + if !slices.Equal(req.Header[k], v) { + return fmt.Errorf("invalid %q header value, got %q, want %q", k, req.Header[k], v) + } + + return nil +} diff --git a/platform/tester/servermock/link_query.go b/platform/tester/servermock/link_query.go new file mode 100644 index 000000000..14f776515 --- /dev/null +++ b/platform/tester/servermock/link_query.go @@ -0,0 +1,100 @@ +package servermock + +import ( + "fmt" + "net/http" + "net/url" + "regexp" +) + +// QueryParameterLink validates query parameters in HTTP requests. +// The strict flag enforces exact matches with specified query parameters. +type QueryParameterLink struct { + values map[string]string + regexes map[string]*regexp.Regexp + strict bool + statusCode int +} + +func CheckQueryParameter() *QueryParameterLink { + return &QueryParameterLink{ + values: map[string]string{}, + regexes: map[string]*regexp.Regexp{}, + statusCode: http.StatusBadRequest, + } +} + +func (l *QueryParameterLink) Bind(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + query := req.URL.Query() + + if l.strict { + if len(query) != len(l.values)+len(l.regexes) { + msg := fmt.Sprintf("invalid query parameters, got %v, want %v", query, l.values) + http.Error(rw, msg, l.statusCode) + + return + } + } + + for k, v := range l.values { + p := query.Get(k) + if p != v { + msg := fmt.Sprintf("invalid %q query parameter value, got %q, want %q", k, p, v) + http.Error(rw, msg, l.statusCode) + + return + } + } + + for k, exp := range l.regexes { + value := query.Get(k) + if !exp.MatchString(value) { + msg := fmt.Sprintf("invalid %q query parameter value, %q doesn't match to %q", k, value, exp) + http.Error(rw, msg, l.statusCode) + + return + } + } + + next.ServeHTTP(rw, req) + }) +} + +func (l *QueryParameterLink) Strict() *QueryParameterLink { + l.strict = true + + return l +} + +func (l *QueryParameterLink) With(name, value string) *QueryParameterLink { + l.values[name] = value + + return l +} + +func (l *QueryParameterLink) WithRegexp(name, exp string) *QueryParameterLink { + l.regexes[name] = regexp.MustCompile(exp) + + return l +} + +func (l *QueryParameterLink) WithValues(values url.Values) *QueryParameterLink { + for k, v := range values { + if len(v) != 1 { + continue + } + + l.values[k] = v[0] + } + + return l +} + +func (l *QueryParameterLink) WithStatusCode(status int) *QueryParameterLink { + if l.statusCode >= http.StatusContinue { + l.statusCode = status + } + + return l +} diff --git a/platform/tester/servermock/link_request_body.go b/platform/tester/servermock/link_request_body.go new file mode 100644 index 000000000..d6b2d9efd --- /dev/null +++ b/platform/tester/servermock/link_request_body.go @@ -0,0 +1,100 @@ +package servermock + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "slices" +) + +// RequestBodyLink represents a handler utility to validate HTTP request bodies against a predefined byte slice. +type RequestBodyLink struct { + body []byte + filename string + ignoreWhitespace bool +} + +// CheckRequestBody creates a [RequestBodyLink] initialized with the provided request body string. +func CheckRequestBody(body string) *RequestBodyLink { + return &RequestBodyLink{body: []byte(body)} +} + +// CheckRequestBodyFromFile creates a [RequestBodyLink] initialized with the provided request body file. +func CheckRequestBodyFromFile(filename string) *RequestBodyLink { + return &RequestBodyLink{filename: filename} +} + +// CheckRequestBodyFromFixture creates a [RequestBodyLink] initialized with the provided request body file from the `fixtures` directory. +func CheckRequestBodyFromFixture(filename string) *RequestBodyLink { + return CheckRequestBodyFromFile(filepath.Join("fixtures", filename)) +} + +// CheckRequestBodyFromInternal creates a [RequestBodyLink] initialized with the provided request body file from the `internal/fixtures directory. +func CheckRequestBodyFromInternal(filename string) *RequestBodyLink { + return CheckRequestBodyFromFile(filepath.Join("internal", "fixtures", filename)) +} + +func (l *RequestBodyLink) Bind(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.ContentLength == 0 { + http.Error(rw, fmt.Sprintf("%s: empty request body", req.URL.Path), http.StatusBadRequest) + return + } + + body, err := io.ReadAll(req.Body) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + _ = req.Body.Close() + + expectedRaw := slices.Clone(l.body) + + if l.filename != "" { + expectedRaw, err = os.ReadFile(l.filename) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + } + + if len(expectedRaw) == 0 { + http.Error(rw, fmt.Sprintf("%s: empty expected request body", req.URL.Path), http.StatusBadRequest) + return + } + + if l.ignoreWhitespace { + body = trimLineSpace(body) + expectedRaw = trimLineSpace(expectedRaw) + } + + if !bytes.Equal(bytes.TrimSpace(expectedRaw), bytes.TrimSpace(body)) { + msg := fmt.Sprintf("%s: request body differences: got: %s, want: %s", req.URL.Path, + string(bytes.TrimSpace(body)), string(bytes.TrimSpace(expectedRaw))) + http.Error(rw, msg, http.StatusBadRequest) + + return + } + + next.ServeHTTP(rw, req) + }) +} + +func (l *RequestBodyLink) IgnoreWhitespace() *RequestBodyLink { + l.ignoreWhitespace = true + + return l +} + +func trimLineSpace(body []byte) []byte { + buf := bytes.NewBuffer(nil) + for line := range bytes.Lines(body) { + buf.Write(bytes.TrimSpace(line)) + } + + return buf.Bytes() +} diff --git a/platform/tester/servermock/link_request_body_json.go b/platform/tester/servermock/link_request_body_json.go new file mode 100644 index 000000000..ed5a117ba --- /dev/null +++ b/platform/tester/servermock/link_request_body_json.go @@ -0,0 +1,114 @@ +package servermock + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "slices" + + "github.com/google/go-cmp/cmp" +) + +// RequestBodyJSONLink validates JSON request bodies. +type RequestBodyJSONLink struct { + body []byte + filename string + data any +} + +// CheckRequestJSONBody creates a [RequestBodyJSONLink] initialized with a string. +func CheckRequestJSONBody(body string) *RequestBodyJSONLink { + return &RequestBodyJSONLink{body: []byte(body)} +} + +// CheckRequestJSONBodyFromStruct creates a [RequestBodyJSONLink] initialized with a struct. +func CheckRequestJSONBodyFromStruct(data any) *RequestBodyJSONLink { + return &RequestBodyJSONLink{data: data} +} + +// CheckRequestJSONBodyFromFile creates a [RequestBodyJSONLink] initialized with the provided request body file. +func CheckRequestJSONBodyFromFile(filename string) *RequestBodyJSONLink { + return &RequestBodyJSONLink{ + filename: filename, + } +} + +// CheckRequestJSONBodyFromFixture creates a [RequestBodyJSONLink] initialized with the provided request body file from the `fixtures` directory. +func CheckRequestJSONBodyFromFixture(filename string) *RequestBodyJSONLink { + return CheckRequestJSONBodyFromFile(filepath.Join("fixtures", filename)) +} + +// CheckRequestJSONBodyFromInternal creates a [RequestBodyJSONLink] initialized with the provided request body file from the `internal/fixtures` directory. +func CheckRequestJSONBodyFromInternal(filename string) *RequestBodyJSONLink { + return CheckRequestJSONBodyFromFile(filepath.Join("internal", "fixtures", filename)) +} + +func (l *RequestBodyJSONLink) Bind(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.ContentLength == 0 { + http.Error(rw, fmt.Sprintf("%s: empty request body", req.URL.Path), http.StatusBadRequest) + return + } + + body, err := io.ReadAll(req.Body) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + _ = req.Body.Close() + + var expected, actual any + + expectedRaw := slices.Clone(l.body) + + switch { + case l.filename != "": + expectedRaw, err = os.ReadFile(l.filename) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + case l.data != nil: + expectedRaw, err = json.Marshal(l.data) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + } + + if len(expectedRaw) == 0 { + http.Error(rw, fmt.Sprintf("%s: empty expected request body", req.URL.Path), http.StatusBadRequest) + return + } + + err = json.Unmarshal(expectedRaw, &expected) + if err != nil { + msg := fmt.Sprintf("%s: the expected request body is not valid JSON: %v", req.URL.Path, err) + http.Error(rw, msg, http.StatusBadRequest) + + return + } + + err = json.Unmarshal(body, &actual) + if err != nil { + msg := fmt.Sprintf("%s: request body is not valid JSON: %v", req.URL.Path, err) + http.Error(rw, msg, http.StatusBadRequest) + + return + } + + if !cmp.Equal(actual, expected) { + msg := fmt.Sprintf("%s: request body differences: %s", req.URL.Path, cmp.Diff(actual, expected)) + http.Error(rw, msg, http.StatusBadRequest) + + return + } + + next.ServeHTTP(rw, req) + }) +} diff --git a/platform/wait/wait.go b/platform/wait/wait.go index 6ad817b26..c66f57446 100644 --- a/platform/wait/wait.go +++ b/platform/wait/wait.go @@ -1,9 +1,11 @@ package wait import ( + "context" "fmt" "time" + "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/log" ) @@ -12,21 +14,25 @@ func For(msg string, timeout, interval time.Duration, f func() (bool, error)) er log.Infof("Wait for %s [timeout: %s, interval: %s]", msg, timeout, interval) var lastErr error + timeUp := time.After(timeout) + for { select { case <-timeUp: if lastErr == nil { return fmt.Errorf("%s: time limit exceeded", msg) } + return fmt.Errorf("%s: time limit exceeded: last error: %w", msg, lastErr) default: } stop, err := f() if stop { - return nil + return err } + if err != nil { lastErr = err } @@ -34,3 +40,13 @@ func For(msg string, timeout, interval time.Duration, f func() (bool, error)) er time.Sleep(interval) } } + +// Retry retries the given operation until it succeeds or the context is canceled. +// Similar to [backoff.Retry] but with a different signature. +func Retry(ctx context.Context, operation func() error, opts ...backoff.RetryOption) error { + _, err := backoff.Retry(ctx, func() (any, error) { + return nil, operation() + }, opts...) + + return err +} diff --git a/platform/wait/wait_test.go b/platform/wait/wait_test.go index 9722e6f2e..36dbffe69 100644 --- a/platform/wait/wait_test.go +++ b/platform/wait/wait_test.go @@ -1,26 +1,121 @@ package wait import ( + "errors" + "sync/atomic" "testing" "time" + + "github.com/stretchr/testify/require" ) -func TestForTimeout(t *testing.T) { +// TODO(ldez): rewrite those tests when upgrading to go1.25 as minimum Go version. + +func TestFor_timeout(t *testing.T) { + var io atomic.Int64 + c := make(chan error) + go func() { - c <- For("", 3*time.Second, 1*time.Second, func() (bool, error) { + c <- For("test", 3*time.Second, 1*time.Second, func() (bool, error) { + io.Add(1) + + if io.Load() == 1 { + return false, nil + } + return false, nil }) }() timeout := time.After(6 * time.Second) + select { case <-timeout: t.Fatal("timeout exceeded") case err := <-c: - if err == nil { - t.Errorf("expected timeout error; got %v", err) - } - t.Logf("%v", err) + require.EqualError(t, err, "test: time limit exceeded") } + + require.EqualValues(t, 3, io.Load()) +} + +func TestFor_timeout_with_error(t *testing.T) { + var io atomic.Int64 + + c := make(chan error) + + go func() { + c <- For("test", 3*time.Second, 1*time.Second, func() (bool, error) { + io.Add(1) + + // This allows be sure that the latest previous error is returned. + if io.Load() == 1 { + return false, errors.New("oops") + } + + return false, nil + }) + }() + + timeout := time.After(6 * time.Second) + + select { + case <-timeout: + t.Fatal("timeout exceeded") + case err := <-c: + require.EqualError(t, err, "test: time limit exceeded: last error: oops") + } + + require.EqualValues(t, 3, io.Load()) +} + +func TestFor_stop(t *testing.T) { + var io atomic.Int64 + + c := make(chan error) + + go func() { + c <- For("test", 3*time.Second, 1*time.Second, func() (bool, error) { + io.Add(1) + + return true, nil + }) + }() + + timeout := time.After(6 * time.Second) + + select { + case <-timeout: + t.Fatal("timeout exceeded") + case err := <-c: + require.NoError(t, err) + } + + require.EqualValues(t, 1, io.Load()) +} + +func TestFor_stop_with_error(t *testing.T) { + var io atomic.Int64 + + c := make(chan error) + + go func() { + c <- For("test", 3*time.Second, 1*time.Second, func() (bool, error) { + io.Add(1) + + return true, errors.New("oops") + }) + }() + + timeout := time.After(6 * time.Second) + + select { + case <-timeout: + t.Fatal("timeout exceeded") + case err := <-c: + require.EqualError(t, err, "oops") + } + + require.EqualValues(t, 1, io.Load()) } diff --git a/providers/dns/acmedns/acmedns.go b/providers/dns/acmedns/acmedns.go index b7ec31653..8f1f16842 100644 --- a/providers/dns/acmedns/acmedns.go +++ b/providers/dns/acmedns/acmedns.go @@ -3,12 +3,17 @@ package acmedns import ( + "context" "errors" "fmt" + "strings" - "github.com/cpu/goacmedns" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/acmedns/internal" + "github.com/nrdcg/goacmedns" + "github.com/nrdcg/goacmedns/storage" ) const ( @@ -18,54 +23,112 @@ const ( // EnvAPIBase is the environment variable name for the ACME-DNS API address. // (e.g. https://acmedns.your-domain.com). EnvAPIBase = envNamespace + "API_BASE" + + // EnvAllowList are source networks using CIDR notation, + // e.g. "192.168.100.1/24,1.2.3.4/32,2002:c0a8:2a00::0/40". + EnvAllowList = envNamespace + "ALLOWLIST" + // EnvStoragePath is the environment variable name for the ACME-DNS JSON account data file. // A per-domain account will be registered/persisted to this file and used for TXT updates. EnvStoragePath = envNamespace + "STORAGE_PATH" + + // EnvStorageBaseURL is the environment variable name for the ACME-DNS JSON account data. + // The URL to the storage server. + EnvStorageBaseURL = envNamespace + "STORAGE_BASE_URL" ) +var _ challenge.Provider = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIBase string + AllowList []string + StoragePath string + StorageBaseURL string +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{} +} + // acmeDNSClient is an interface describing the goacmedns.Client functions the DNSProvider uses. // It makes it easier for tests to shim a mock Client into the DNSProvider. type acmeDNSClient interface { // UpdateTXTRecord updates the provided account's TXT record // to the given value or returns an error. - UpdateTXTRecord(account goacmedns.Account, value string) error + UpdateTXTRecord(ctx context.Context, account goacmedns.Account, value string) error // RegisterAccount registers and returns a new account // with the given allowFrom restriction or returns an error. - RegisterAccount(allowFrom []string) (goacmedns.Account, error) + RegisterAccount(ctx context.Context, allowFrom []string) (goacmedns.Account, error) } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { + config *Config client acmeDNSClient storage goacmedns.Storage } -// NewDNSProvider creates an ACME-DNS provider using file based account storage. -// Its configuration is loaded from the environment by reading EnvAPIBase and EnvStoragePath. +// NewDNSProvider returns a DNSProvider instance configured for Joohoi's acme-dns. func NewDNSProvider() (*DNSProvider, error) { - values, err := env.Get(EnvAPIBase, EnvStoragePath) + values, err := env.Get(EnvAPIBase) if err != nil { return nil, fmt.Errorf("acme-dns: %w", err) } - client := goacmedns.NewClient(values[EnvAPIBase]) - storage := goacmedns.NewFileStorage(values[EnvStoragePath], 0o600) - return NewDNSProviderClient(client, storage) -} + config := NewDefaultConfig() + config.APIBase = values[EnvAPIBase] + config.StoragePath = env.GetOrFile(EnvStoragePath) + config.StorageBaseURL = env.GetOrFile(EnvStorageBaseURL) -// NewDNSProviderClient creates an ACME-DNS DNSProvider with the given acmeDNSClient and goacmedns.Storage. -func NewDNSProviderClient(client acmeDNSClient, storage goacmedns.Storage) (*DNSProvider, error) { - if client == nil { - return nil, errors.New("ACME-DNS Client must be not nil") + allowList := env.GetOrFile(EnvAllowList) + if allowList != "" { + config.AllowList = strings.Split(allowList, ",") } - if storage == nil { - return nil, errors.New("ACME-DNS Storage must be not nil") + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Joohoi's acme-dns. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("acme-dns: the configuration of the DNS provider is nil") + } + + st, err := getStorage(config) + if err != nil { + return nil, fmt.Errorf("acme-dns: %w", err) + } + + client, err := goacmedns.NewClient(config.APIBase) + if err != nil { + return nil, fmt.Errorf("acme-dns: new client: %w", err) } return &DNSProvider{ + config: config, client: client, - storage: storage, + storage: st, + }, nil +} + +// NewDNSProviderClient creates an ACME-DNS DNSProvider with the given acmeDNSClient and [goacmedns.Storage]. +// +// Deprecated: use [NewDNSProviderConfig] instead. +func NewDNSProviderClient(client acmeDNSClient, store goacmedns.Storage) (*DNSProvider, error) { + if client == nil { + return nil, errors.New("acme-dns: Client must be not nil") + } + + if store == nil { + return nil, errors.New("acme-dns: Storage must be not nil") + } + + return &DNSProvider{ + config: NewDefaultConfig(), + client: client, + storage: store, }, nil } @@ -102,24 +165,28 @@ func (e ErrCNAMERequired) Error() string { // one will be created and registered with the ACME DNS server and an ErrCNAMERequired error is returned. // This will halt issuance and indicate to the user that a one-time manual setup is required for the domain. func (d *DNSProvider) Present(domain, _, keyAuth string) error { + ctx := context.Background() + // Compute the challenge response FQDN and TXT value for the domain based on the keyAuth. info := dns01.GetChallengeInfo(domain, keyAuth) // Check if credentials were previously saved for this domain. - account, err := d.storage.Fetch(domain) + account, err := d.storage.Fetch(ctx, domain) if err != nil { - if errors.Is(err, goacmedns.ErrDomainNotFound) { - // The account did not exist. - // Create a new one and return an error indicating the required one-time manual CNAME setup. - return d.register(domain, info.FQDN) + if !errors.Is(err, storage.ErrDomainNotFound) { + return err } - // Errors other than goacmedns.ErrDomainNotFound are unexpected. - return err + // The account did not exist. + // Create a new one and return an error indicating the required one-time manual CNAME setup. + account, err = d.register(ctx, domain, info.FQDN) + if err != nil { + return err + } } // Update the acme-dns TXT record. - return d.client.UpdateTXTRecord(account, info.Value) + return d.client.UpdateTXTRecord(ctx, account, info.Value) } // CleanUp removes the record matching the specified parameters. It is not @@ -134,29 +201,59 @@ func (d *DNSProvider) CleanUp(_, _, _ string) error { // If account creation works as expected a ErrCNAMERequired error is returned describing // the one-time manual CNAME setup required to complete setup of the ACME-DNS hook for the domain. // If any other error occurs it is returned as-is. -func (d *DNSProvider) register(domain, fqdn string) error { - // TODO(@cpu): Read CIDR whitelists from the environment - newAcct, err := d.client.RegisterAccount(nil) +func (d *DNSProvider) register(ctx context.Context, domain, fqdn string) (goacmedns.Account, error) { + newAcct, err := d.client.RegisterAccount(ctx, d.config.AllowList) if err != nil { - return err + return goacmedns.Account{}, err } + var cnameCreated bool + // Store the new account in the storage and call save to persist the data. - err = d.storage.Put(domain, newAcct) + err = d.storage.Put(ctx, domain, newAcct) if err != nil { - return err + cnameCreated = errors.Is(err, internal.ErrCNAMEAlreadyCreated) + if !cnameCreated { + return goacmedns.Account{}, err + } } - err = d.storage.Save() + + err = d.storage.Save(ctx) if err != nil { - return err + return goacmedns.Account{}, err + } + + if cnameCreated { + return newAcct, nil } // Stop issuance by returning an error. // The user needs to perform a manual one-time CNAME setup in their DNS zone // to complete the setup of the new account we created. - return ErrCNAMERequired{ + return goacmedns.Account{}, ErrCNAMERequired{ Domain: domain, FQDN: fqdn, Target: newAcct.FullDomain, } } + +func getStorage(config *Config) (goacmedns.Storage, error) { + if config.StoragePath == "" && config.StorageBaseURL == "" { + return nil, errors.New("storagePath or storageBaseURL is not set") + } + + if config.StoragePath != "" && config.StorageBaseURL != "" { + return nil, errors.New("storagePath and storageBaseURL cannot be used at the same time") + } + + if config.StoragePath != "" { + return storage.NewFile(config.StoragePath, 0o600), nil + } + + st, err := internal.NewHTTPStorage(config.StorageBaseURL) + if err != nil { + return nil, fmt.Errorf("new HTTP storage: %w", err) + } + + return st, nil +} diff --git a/providers/dns/acmedns/acmedns.toml b/providers/dns/acmedns/acmedns.toml index f4632411b..e491569b0 100644 --- a/providers/dns/acmedns/acmedns.toml +++ b/providers/dns/acmedns/acmedns.toml @@ -8,14 +8,23 @@ Since = "v1.1.0" Example = ''' ACME_DNS_API_BASE=http://10.0.0.8:4443 \ ACME_DNS_STORAGE_PATH=/root/.lego-acme-dns-accounts.json \ -lego --email you@example.com --dns "acme-dns" -d '*.example.com' -d example.com run +lego --dns "acme-dns" -d '*.example.com' -d example.com run + +# or + +ACME_DNS_API_BASE=http://10.0.0.8:4443 \ +ACME_DNS_STORAGE_BASE_URL=http://10.10.10.10:80 \ +lego --dns "acme-dns" -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ACME_DNS_API_BASE = "The ACME-DNS API address" ACME_DNS_STORAGE_PATH = "The ACME-DNS JSON account data file. A per-domain account will be registered/persisted to this file and used for TXT updates." + ACME_DNS_STORAGE_BASE_URL = "The ACME-DNS JSON account data server." + [Configuration.Additional] + ACME_DNS_ALLOWLIST = "Source networks using CIDR notation (multiple values should be separated with a comma)." [Links] API = "https://github.com/joohoi/acme-dns#api" - GoClient = "https://github.com/cpu/goacmedns" + GoClient = "https://github.com/nrdcg/goacmedns" diff --git a/providers/dns/acmedns/acmedns_test.go b/providers/dns/acmedns/acmedns_test.go index 68e8f7406..a3ab59d59 100644 --- a/providers/dns/acmedns/acmedns_test.go +++ b/providers/dns/acmedns/acmedns_test.go @@ -1,170 +1,28 @@ package acmedns import ( - "errors" + "net/http" + "net/http/httptest" "testing" - "github.com/cpu/goacmedns" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/nrdcg/goacmedns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -var ( - // errorClientErr is used by the Client mocks that return an error. - errorClientErr = errors.New("errorClient always errors") - // errorStorageErr is used by the Storage mocks that return an error. - errorStorageErr = errors.New("errorStorage always errors") -) - const ( - // Fixed test data for unit tests. egDomain = "example.com" egFQDN = "_acme-challenge." + egDomain + "." egKeyAuth = "⚷" ) -var egTestAccount = goacmedns.Account{ - FullDomain: "acme-dns." + egDomain, - SubDomain: "random-looking-junk." + egDomain, - Username: "spooky.mulder", - Password: "trustno1", -} - -// mockClient is a mock implementing the acmeDNSClient interface that always -// returns a fixed goacmedns.Account from calls to Register. -type mockClient struct { - mockAccount goacmedns.Account -} - -// UpdateTXTRecord does nothing. -func (c mockClient) UpdateTXTRecord(_ goacmedns.Account, _ string) error { - return nil -} - -// RegisterAccount returns c.mockAccount and no errors. -func (c mockClient) RegisterAccount(_ []string) (goacmedns.Account, error) { - return c.mockAccount, nil -} - -// mockUpdateClient is a mock implementing the acmeDNSClient interface that -// tracks the calls to UpdateTXTRecord in the records map. -type mockUpdateClient struct { - mockClient - records map[goacmedns.Account]string -} - -// UpdateTXTRecord saves a record value to c.records for the given acct. -func (c mockUpdateClient) UpdateTXTRecord(acct goacmedns.Account, value string) error { - c.records[acct] = value - return nil -} - -// errorUpdateClient is a mock implementing the acmeDNSClient interface that always -// returns errors from errorUpdateClient. -type errorUpdateClient struct { - mockClient -} - -// UpdateTXTRecord always returns an error. -func (c errorUpdateClient) UpdateTXTRecord(_ goacmedns.Account, _ string) error { - return errorClientErr -} - -// errorRegisterClient is a mock implementing the acmeDNSClient interface that always -// returns errors from RegisterAccount. -type errorRegisterClient struct { - mockClient -} - -// RegisterAccount always returns an error. -func (c errorRegisterClient) RegisterAccount(_ []string) (goacmedns.Account, error) { - return goacmedns.Account{}, errorClientErr -} - -// mockStorage is a mock implementing the goacmedns.Storage interface that -// returns static account data and ignores Save. -type mockStorage struct { - accounts map[string]goacmedns.Account -} - -// Save does nothing. -func (m mockStorage) Save() error { - return nil -} - -// Put stores an account for the given domain in m.accounts. -func (m mockStorage) Put(domain string, acct goacmedns.Account) error { - m.accounts[domain] = acct - return nil -} - -// Fetch retrieves an account for the given domain from m.accounts or returns -// goacmedns.ErrDomainNotFound. -func (m mockStorage) Fetch(domain string) (goacmedns.Account, error) { - if acct, ok := m.accounts[domain]; ok { - return acct, nil - } - return goacmedns.Account{}, goacmedns.ErrDomainNotFound -} - -// FetchAll returns all of m.accounts. -func (m mockStorage) FetchAll() map[string]goacmedns.Account { - return m.accounts -} - -// errorPutStorage is a mock implementing the goacmedns.Storage interface that -// always returns errors from Put. -type errorPutStorage struct { - mockStorage -} - -// Put always errors. -func (e errorPutStorage) Put(_ string, _ goacmedns.Account) error { - return errorStorageErr -} - -// errorSaveStorage is a mock implementing the goacmedns.Storage interface that -// always returns errors from Save. -type errorSaveStorage struct { - mockStorage -} - -// Save always errors. -func (e errorSaveStorage) Save() error { - return errorStorageErr -} - -// errorFetchStorage is a mock implementing the goacmedns.Storage interface that -// always returns errors from Fetch. -type errorFetchStorage struct { - mockStorage -} - -// Fetch always errors. -func (e errorFetchStorage) Fetch(_ string) (goacmedns.Account, error) { - return goacmedns.Account{}, errorStorageErr -} - -// FetchAll is a nop for errorFetchStorage. -func (e errorFetchStorage) FetchAll() map[string]goacmedns.Account { - return nil -} - -// TestPresent tests that the ACME-DNS Present function for updating a DNS-01 -// challenge response TXT record works as expected. func TestPresent(t *testing.T) { // validAccountStorage is a mockStorage configured to return the egTestAccount. - validAccountStorage := mockStorage{ - map[string]goacmedns.Account{ - egDomain: egTestAccount, - }, - } - // validUpdateClient is a mockClient configured with the egTestAccount that will - // track TXT updates in a map. - validUpdateClient := mockUpdateClient{ - mockClient{egTestAccount}, - make(map[goacmedns.Account]string), - } + validAccountStorage := newMockStorage().WithAccount(egDomain, egTestAccount) + + // validUpdateClient is a mockClient configured with the egTestAccount that will track TXT updates in a map. + validUpdateClient := newMockClient() testCases := []struct { Name string @@ -174,13 +32,13 @@ func TestPresent(t *testing.T) { }{ { Name: "present when client storage returns unexpected error", - Client: mockClient{egTestAccount}, - Storage: errorFetchStorage{}, + Client: newMockClient().WithRegisterAccount(egTestAccount), + Storage: newMockStorage().WithFetchError(errorStorageErr), ExpectedError: errorStorageErr, }, { Name: "present when client storage returns ErrDomainNotFound", - Client: mockClient{egTestAccount}, + Client: newMockClient().WithRegisterAccount(egTestAccount), ExpectedError: ErrCNAMERequired{ Domain: egDomain, FQDN: egFQDN, @@ -189,7 +47,7 @@ func TestPresent(t *testing.T) { }, { Name: "present when client UpdateTXTRecord returns unexpected error", - Client: errorUpdateClient{}, + Client: newMockClient().WithUpdateTXTRecordError(errorClientErr), Storage: validAccountStorage, ExpectedError: errorClientErr, }, @@ -202,17 +60,17 @@ func TestPresent(t *testing.T) { for _, test := range testCases { t.Run(test.Name, func(t *testing.T) { - dp, err := NewDNSProviderClient(test.Client, mockStorage{make(map[string]goacmedns.Account)}) - require.NoError(t, err) - - // override the storage mock if required by the test case. - if test.Storage != nil { - dp.storage = test.Storage + p := &DNSProvider{ + config: NewDefaultConfig(), + client: test.Client, + storage: newMockStorage(), } - // call Present. The token argument can be garbage because the ACME-DNS - // provider does not use it. - err = dp.Present(egDomain, "foo", egKeyAuth) + if test.Storage != nil { + p.storage = test.Storage + } + + err := p.Present(egDomain, "foo", egKeyAuth) if test.ExpectedError != nil { assert.Equal(t, test.ExpectedError, err) } else { @@ -228,36 +86,33 @@ func TestPresent(t *testing.T) { assert.Len(t, validUpdateClient.records[egTestAccount], 43) } -// TestRegister tests that the ACME-DNS register function works correctly. func TestRegister(t *testing.T) { testCases := []struct { Name string Client acmeDNSClient Storage goacmedns.Storage - Domain string - FQDN string ExpectedError error }{ { Name: "register when acme-dns client returns an error", - Client: errorRegisterClient{}, + Client: newMockClient().WithRegisterAccountError(errorClientErr), ExpectedError: errorClientErr, }, { Name: "register when acme-dns storage put returns an error", - Client: mockClient{egTestAccount}, - Storage: errorPutStorage{mockStorage{make(map[string]goacmedns.Account)}}, + Client: newMockClient().WithRegisterAccount(egTestAccount), + Storage: newMockStorage().WithPutError(errorStorageErr), ExpectedError: errorStorageErr, }, { Name: "register when acme-dns storage save returns an error", - Client: mockClient{egTestAccount}, - Storage: errorSaveStorage{mockStorage{make(map[string]goacmedns.Account)}}, + Client: newMockClient().WithRegisterAccount(egTestAccount), + Storage: newMockStorage().WithSaveError(errorStorageErr), ExpectedError: errorStorageErr, }, { Name: "register when everything works", - Client: mockClient{egTestAccount}, + Client: newMockClient().WithRegisterAccount(egTestAccount), ExpectedError: ErrCNAMERequired{ Domain: egDomain, FQDN: egFQDN, @@ -268,21 +123,121 @@ func TestRegister(t *testing.T) { for _, test := range testCases { t.Run(test.Name, func(t *testing.T) { - dp, err := NewDNSProviderClient(test.Client, mockStorage{make(map[string]goacmedns.Account)}) - require.NoError(t, err) - - // override the storage mock if required by the testcase. - if test.Storage != nil { - dp.storage = test.Storage + p := &DNSProvider{ + config: NewDefaultConfig(), + client: test.Client, + storage: newMockStorage(), } - // Call register for the example domain/fqdn. - err = dp.register(egDomain, egFQDN) + if test.Storage != nil { + p.storage = test.Storage + } + + acc, err := p.register(t.Context(), egDomain, egFQDN) if test.ExpectedError != nil { assert.Equal(t, test.ExpectedError, err) } else { + assert.Equal(t, goacmedns.Account{}, acc) require.NoError(t, err) } }) } } + +func TestPresent_httpStorage(t *testing.T) { + testCases := []struct { + desc string + StatusCode int + ExpectedError error + }{ + { + desc: "the CNAME is not handled by the storage", + StatusCode: http.StatusOK, + ExpectedError: ErrCNAMERequired{ + Domain: egDomain, + FQDN: egFQDN, + Target: egTestAccount.FullDomain, + }, + }, + { + desc: "the CNAME is handled by the storage", + StatusCode: http.StatusCreated, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + provider := servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.StorageBaseURL = server.URL + + return NewDNSProviderConfig(config) + }). + // Fetch + Route("GET /example.com", servermock.Noop().WithStatusCode(http.StatusNotFound)). + // Put + Route("POST /example.com", servermock.Noop().WithStatusCode(test.StatusCode)). + Build(t) + + client := newMockClient().WithRegisterAccount(egTestAccount) + provider.client = client + + err := provider.Present(egDomain, "foo", egKeyAuth) + if test.ExpectedError != nil { + assert.EqualError(t, err, test.ExpectedError.Error()) + assert.True(t, client.registerAccountCalled) + assert.False(t, client.updateTXTRecordCalled) + } else { + require.NoError(t, err) + assert.True(t, client.registerAccountCalled) + assert.True(t, client.updateTXTRecordCalled) + } + }) + } +} + +func TestRegister_httpStorage(t *testing.T) { + testCases := []struct { + Name string + StatusCode int + ExpectedError error + }{ + { + Name: "status code 200", + StatusCode: http.StatusOK, + ExpectedError: ErrCNAMERequired{ + Domain: egDomain, + FQDN: egFQDN, + Target: egTestAccount.FullDomain, + }, + }, + { + Name: "status code 201", + StatusCode: http.StatusCreated, + }, + } + + for _, test := range testCases { + t.Run(test.Name, func(t *testing.T) { + provider := servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.StorageBaseURL = server.URL + + return NewDNSProviderConfig(config) + }). + // Put + Route("POST /example.com", servermock.Noop().WithStatusCode(test.StatusCode)). + Build(t) + + provider.client = newMockClient().WithRegisterAccount(egTestAccount) + + acc, err := provider.register(t.Context(), egDomain, egFQDN) + if test.ExpectedError != nil { + assert.Equal(t, test.ExpectedError, err) + } else { + require.NoError(t, err) + assert.Equal(t, egTestAccount, acc) + } + }) + } +} diff --git a/providers/dns/acmedns/internal/fixtures/error.json b/providers/dns/acmedns/internal/fixtures/error.json new file mode 100644 index 000000000..d1b2ba3be --- /dev/null +++ b/providers/dns/acmedns/internal/fixtures/error.json @@ -0,0 +1,3 @@ +{ + "message": "There is an error" +} diff --git a/providers/dns/acmedns/internal/fixtures/fetch-request.json b/providers/dns/acmedns/internal/fixtures/fetch-request.json new file mode 100644 index 000000000..d29cebc5b --- /dev/null +++ b/providers/dns/acmedns/internal/fixtures/fetch-request.json @@ -0,0 +1,7 @@ +{ + "fulldomain": "foo.example.com", + "subdomain": "foo", + "username": "user", + "password": "secret", + "server_url": "https://example.com" +} diff --git a/providers/dns/acmedns/internal/fixtures/fetch.json b/providers/dns/acmedns/internal/fixtures/fetch.json new file mode 100644 index 000000000..d29cebc5b --- /dev/null +++ b/providers/dns/acmedns/internal/fixtures/fetch.json @@ -0,0 +1,7 @@ +{ + "fulldomain": "foo.example.com", + "subdomain": "foo", + "username": "user", + "password": "secret", + "server_url": "https://example.com" +} diff --git a/providers/dns/acmedns/internal/fixtures/fetch_all.json b/providers/dns/acmedns/internal/fixtures/fetch_all.json new file mode 100644 index 000000000..9ea557b38 --- /dev/null +++ b/providers/dns/acmedns/internal/fixtures/fetch_all.json @@ -0,0 +1,16 @@ +{ + "a": { + "fulldomain": "foo.example.com", + "subdomain": "foo", + "username": "user", + "password": "secret", + "server_url": "https://example.com" + }, + "b": { + "fulldomain": "bar.example.com", + "subdomain": "bar", + "username": "user", + "password": "secret", + "server_url": "https://example.com" + } +} diff --git a/providers/dns/acmedns/internal/http_storage.go b/providers/dns/acmedns/internal/http_storage.go new file mode 100644 index 000000000..7a535eb20 --- /dev/null +++ b/providers/dns/acmedns/internal/http_storage.go @@ -0,0 +1,147 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/nrdcg/goacmedns" + "github.com/nrdcg/goacmedns/storage" +) + +var _ goacmedns.Storage = (*HTTPStorage)(nil) + +var ErrCNAMEAlreadyCreated = errors.New("the CNAME has already been created") + +// HTTPStorage is an implementation of [acmedns.Storage] over HTTP. +type HTTPStorage struct { + client *http.Client + baseURL *url.URL +} + +// NewHTTPStorage created a new [HTTPStorage]. +func NewHTTPStorage(baseURL string) (*HTTPStorage, error) { + endpoint, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + return &HTTPStorage{ + client: &http.Client{Timeout: 2 * time.Minute}, + baseURL: endpoint, + }, nil +} + +func (s *HTTPStorage) Save(_ context.Context) error { + return nil +} + +func (s *HTTPStorage) Put(ctx context.Context, domain string, account goacmedns.Account) error { + req, err := newJSONRequest(ctx, http.MethodPost, s.baseURL.JoinPath(domain), account) + if err != nil { + return fmt.Errorf("unable to create request: %w", err) + } + + return s.do(req, nil) +} + +func (s *HTTPStorage) Fetch(ctx context.Context, domain string) (goacmedns.Account, error) { + req, err := newJSONRequest(ctx, http.MethodGet, s.baseURL.JoinPath(domain), nil) + if err != nil { + return goacmedns.Account{}, fmt.Errorf("unable to create request: %w", err) + } + + var account goacmedns.Account + + err = s.do(req, &account) + if err != nil { + return goacmedns.Account{}, err + } + + return account, nil +} + +func (s *HTTPStorage) FetchAll(ctx context.Context) (map[string]goacmedns.Account, error) { + req, err := newJSONRequest(ctx, http.MethodGet, s.baseURL, nil) + if err != nil { + return nil, err + } + + var mapping map[string]goacmedns.Account + + err = s.do(req, &mapping) + if err != nil { + return nil, err + } + + return mapping, nil +} + +func (s *HTTPStorage) do(req *http.Request, result any) error { + resp, err := s.client.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return storage.ErrDomainNotFound + } + + if resp.StatusCode/100 != 2 { + return errutils.NewUnexpectedResponseStatusCodeError(req, resp) + } + + if result == nil { + // Hack related to `Put`. + if resp.StatusCode == http.StatusCreated { + return ErrCNAMEAlreadyCreated + } + + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} diff --git a/providers/dns/acmedns/internal/http_storage_test.go b/providers/dns/acmedns/internal/http_storage_test.go new file mode 100644 index 000000000..5c166b47f --- /dev/null +++ b/providers/dns/acmedns/internal/http_storage_test.go @@ -0,0 +1,153 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/nrdcg/goacmedns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*HTTPStorage] { + return servermock.NewBuilder[*HTTPStorage]( + func(server *httptest.Server) (*HTTPStorage, error) { + storage, err := NewHTTPStorage(server.URL) + if err != nil { + return nil, err + } + + storage.client = server.Client() + + return storage, nil + }, + servermock.CheckHeader().WithJSONHeaders()) +} + +func TestHTTPStorage_Fetch(t *testing.T) { + storage := mockBuilder(). + Route("GET /example.com", servermock.ResponseFromFixture("fetch.json")). + Build(t) + + account, err := storage.Fetch(t.Context(), "example.com") + require.NoError(t, err) + + expected := goacmedns.Account{ + FullDomain: "foo.example.com", + SubDomain: "foo", + Username: "user", + Password: "secret", + ServerURL: "https://example.com", + } + + assert.Equal(t, expected, account) +} + +func TestHTTPStorage_Fetch_error(t *testing.T) { + storage := mockBuilder(). + Route("GET /example.com", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusInternalServerError)). + Build(t) + + _, err := storage.Fetch(t.Context(), "example.com") + require.Error(t, err) +} + +func TestHTTPStorage_FetchAll(t *testing.T) { + storage := mockBuilder(). + Route("GET /", servermock.ResponseFromFixture("fetch_all.json")). + Build(t) + + account, err := storage.FetchAll(t.Context()) + require.NoError(t, err) + + expected := map[string]goacmedns.Account{ + "a": { + FullDomain: "foo.example.com", + SubDomain: "foo", + Username: "user", + Password: "secret", + ServerURL: "https://example.com", + }, + "b": { + FullDomain: "bar.example.com", + SubDomain: "bar", + Username: "user", + Password: "secret", + ServerURL: "https://example.com", + }, + } + + assert.Equal(t, expected, account) +} + +func TestHTTPStorage_FetchAll_error(t *testing.T) { + storage := mockBuilder(). + Route("GET /", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusInternalServerError)). + Build(t) + + _, err := storage.FetchAll(t.Context()) + require.Error(t, err) +} + +func TestHTTPStorage_Put(t *testing.T) { + storage := mockBuilder(). + Route("POST /example.com", nil, + servermock.CheckRequestJSONBodyFromFixture("fetch-request.json")). + Build(t) + + account := goacmedns.Account{ + FullDomain: "foo.example.com", + SubDomain: "foo", + Username: "user", + Password: "secret", + ServerURL: "https://example.com", + } + + err := storage.Put(t.Context(), "example.com", account) + require.NoError(t, err) +} + +func TestHTTPStorage_Put_error(t *testing.T) { + storage := mockBuilder(). + Route("POST /example.com", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusInternalServerError)). + Build(t) + + account := goacmedns.Account{ + FullDomain: "foo.example.com", + SubDomain: "foo", + Username: "user", + Password: "secret", + ServerURL: "https://example.com", + } + + err := storage.Put(t.Context(), "example.com", account) + require.Error(t, err) +} + +func TestHTTPStorage_Put_CNAME_created(t *testing.T) { + storage := mockBuilder(). + Route("POST /example.com", + servermock.Noop(). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromFixture("fetch-request.json")). + Build(t) + + account := goacmedns.Account{ + FullDomain: "foo.example.com", + SubDomain: "foo", + Username: "user", + Password: "secret", + ServerURL: "https://example.com", + } + + err := storage.Put(t.Context(), "example.com", account) + require.ErrorIs(t, err, ErrCNAMEAlreadyCreated) +} diff --git a/providers/dns/acmedns/internal/readme.md b/providers/dns/acmedns/internal/readme.md new file mode 100644 index 000000000..b667d3d23 --- /dev/null +++ b/providers/dns/acmedns/internal/readme.md @@ -0,0 +1,72 @@ +# HTTP Storage + +## Fetch + +### Request + +Endpoint: `GET /` + +### Response + +Response status code 200. + +Response body (account): + +```json +{ + "fulldomain": "foo.example.com", + "subdomain": "foo", + "username": "user", + "password": "secret", + "server_url": "https://example.com" +} +``` + +## Fetch All + +### Request + +Endpoint: `GET ` + +### Response + +Response status code 200. + +Response body (domain/account mapping): + +```json +{ + "foo.example.com": { + "fulldomain": "foo.example.com", + "subdomain": "foo", + "username": "user", + "password": "secret", + "server_url": "https://example.com" + }, + "bar.example.com": { + "fulldomain": "bar.example.com", + "subdomain": "bar", + "username": "user", + "password": "secret", + "server_url": "https://example.com" + } +} +``` + +## Put + +### Request + +Endpoint: `POST /` + +### Response + +Response status code: +- 200: the process will be stopped to allow the user to create the CNAME. +- 201: the process will continue without error (the CNAME should be created by the server) + +No expected body. + +## Save + +No dedicated endpoint. diff --git a/providers/dns/acmedns/mock_test.go b/providers/dns/acmedns/mock_test.go new file mode 100644 index 000000000..a09a3ca91 --- /dev/null +++ b/providers/dns/acmedns/mock_test.go @@ -0,0 +1,161 @@ +package acmedns + +import ( + "context" + "errors" + + "github.com/nrdcg/goacmedns" + "github.com/nrdcg/goacmedns/storage" +) + +var ( + // errorClientErr is used by the Client mocks that return an error. + errorClientErr = errors.New("errorClient always errors") + // errorStorageErr is used by the Storage mocks that return an error. + errorStorageErr = errors.New("errorStorage always errors") +) + +var egTestAccount = goacmedns.Account{ + FullDomain: "acme-dns." + egDomain, + SubDomain: "random-looking-junk." + egDomain, + Username: "spooky.mulder", + Password: "trustno1", +} + +type mockClient struct { + records map[goacmedns.Account]string + + updateTXTRecordCalled bool + updateTXTRecord func(ctx context.Context, acct goacmedns.Account, value string) error + + registerAccountCalled bool + registerAccount func(ctx context.Context, allowFrom []string) (goacmedns.Account, error) +} + +func newMockClient() *mockClient { + return &mockClient{ + records: make(map[goacmedns.Account]string), + updateTXTRecord: func(_ context.Context, _ goacmedns.Account, _ string) error { + return nil + }, + registerAccount: func(_ context.Context, _ []string) (goacmedns.Account, error) { + return goacmedns.Account{}, nil + }, + } +} + +func (c *mockClient) UpdateTXTRecord(ctx context.Context, acct goacmedns.Account, value string) error { + c.updateTXTRecordCalled = true + c.records[acct] = value + + return c.updateTXTRecord(ctx, acct, value) +} + +func (c *mockClient) RegisterAccount(ctx context.Context, allowFrom []string) (goacmedns.Account, error) { + c.registerAccountCalled = true + return c.registerAccount(ctx, allowFrom) +} + +func (c *mockClient) WithUpdateTXTRecordError(err error) *mockClient { + c.updateTXTRecord = func(_ context.Context, _ goacmedns.Account, _ string) error { + return err + } + + return c +} + +func (c *mockClient) WithRegisterAccount(acct goacmedns.Account) *mockClient { + c.registerAccount = func(_ context.Context, _ []string) (goacmedns.Account, error) { + return acct, nil + } + + return c +} + +func (c *mockClient) WithRegisterAccountError(err error) *mockClient { + c.registerAccount = func(_ context.Context, _ []string) (goacmedns.Account, error) { + return goacmedns.Account{}, err + } + + return c +} + +type mockStorage struct { + accounts map[string]goacmedns.Account + fetchAll func(ctx context.Context) (map[string]goacmedns.Account, error) + fetch func(ctx context.Context, domain string) (goacmedns.Account, error) + put func(ctx context.Context, domain string, acct goacmedns.Account) error + save func(ctx context.Context) error +} + +func newMockStorage() *mockStorage { + m := &mockStorage{ + accounts: make(map[string]goacmedns.Account), + put: func(_ context.Context, _ string, _ goacmedns.Account) error { + return nil + }, + save: func(_ context.Context) error { + return nil + }, + } + + m.fetchAll = func(ctx context.Context) (map[string]goacmedns.Account, error) { + return m.accounts, nil + } + + m.fetch = func(_ context.Context, domain string) (goacmedns.Account, error) { + if acct, ok := m.accounts[domain]; ok { + return acct, nil + } + + return goacmedns.Account{}, storage.ErrDomainNotFound + } + + return m +} + +func (m *mockStorage) FetchAll(ctx context.Context) (map[string]goacmedns.Account, error) { + return m.fetchAll(ctx) +} + +func (m *mockStorage) Fetch(ctx context.Context, domain string) (goacmedns.Account, error) { + return m.fetch(ctx, domain) +} + +func (m *mockStorage) Put(ctx context.Context, domain string, account goacmedns.Account) error { + return m.put(ctx, domain, account) +} + +func (m *mockStorage) Save(ctx context.Context) error { + return m.save(ctx) +} + +func (m *mockStorage) WithAccount(domain string, acct goacmedns.Account) *mockStorage { + m.accounts[domain] = acct + + return m +} + +func (m *mockStorage) WithFetchError(err error) *mockStorage { + m.fetch = func(_ context.Context, _ string) (goacmedns.Account, error) { + return goacmedns.Account{}, err + } + + return m +} + +func (m *mockStorage) WithPutError(err error) *mockStorage { + m.put = func(_ context.Context, _ string, _ goacmedns.Account) error { + return err + } + + return m +} + +func (m *mockStorage) WithSaveError(err error) *mockStorage { + m.save = func(ctx context.Context) error { + return err + } + + return m +} diff --git a/providers/dns/active24/active24.go b/providers/dns/active24/active24.go new file mode 100644 index 000000000..0b925de6a --- /dev/null +++ b/providers/dns/active24/active24.go @@ -0,0 +1,103 @@ +// Package active24 implements a DNS provider for solving the DNS-01 challenge using Active24. +package active24 + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/active24" +) + +const baseAPIDomain = "active24.cz" + +// Environment variables names. +const ( + envNamespace = "ACTIVE24_" + + EnvAPIKey = envNamespace + "API_KEY" + EnvSecret = envNamespace + "SECRET" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config = active24.Config + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + prv challenge.ProviderTimeout +} + +// NewDNSProvider returns a DNSProvider instance configured for Active24. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey, EnvSecret) + if err != nil { + return nil, fmt.Errorf("active24: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + config.Secret = values[EnvSecret] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Active24. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("active24: the configuration of the DNS provider is nil") + } + + provider, err := active24.NewDNSProviderConfig(config, baseAPIDomain) + if err != nil { + return nil, fmt.Errorf("active24: %w", err) + } + + return &DNSProvider{prv: provider}, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + err := d.prv.Present(domain, token, keyAuth) + if err != nil { + return fmt.Errorf("active24: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + err := d.prv.CleanUp(domain, token, keyAuth) + if err != nil { + return fmt.Errorf("active24: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.prv.Timeout() +} diff --git a/providers/dns/active24/active24.toml b/providers/dns/active24/active24.toml new file mode 100644 index 000000000..b0eaabab8 --- /dev/null +++ b/providers/dns/active24/active24.toml @@ -0,0 +1,25 @@ +Name = "Active24" +Description = '''''' +URL = "https://www.active24.cz" +Code = "active24" +Since = "v4.23.0" + +Example = ''' +ACTIVE24_API_KEY="xxx" \ +ACTIVE24_SECRET="yyy" \ +lego --dns active24 -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + ACTIVE24_API_KEY = "API key" + ACTIVE24_SECRET = "Secret" + [Configuration.Additional] + ACTIVE24_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + ACTIVE24_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + ACTIVE24_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + ACTIVE24_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://rest.active24.cz/v2/docs" + APIv1 = "https://rest.active24.cz/docs/v1.service#services" diff --git a/providers/dns/active24/active24_test.go b/providers/dns/active24/active24_test.go new file mode 100644 index 000000000..2987fb27b --- /dev/null +++ b/providers/dns/active24/active24_test.go @@ -0,0 +1,146 @@ +package active24 + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIKey, EnvSecret).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "user", + EnvSecret: "secret", + }, + }, + { + desc: "missing API key", + envVars: map[string]string{ + EnvAPIKey: "", + EnvSecret: "secret", + }, + expected: "active24: some credentials information are missing: ACTIVE24_API_KEY", + }, + { + desc: "missing secret", + envVars: map[string]string{ + EnvAPIKey: "user", + EnvSecret: "", + }, + expected: "active24: some credentials information are missing: ACTIVE24_SECRET", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "active24: some credentials information are missing: ACTIVE24_API_KEY,ACTIVE24_SECRET", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.prv) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + secret string + expected string + }{ + { + desc: "success", + apiKey: "user", + secret: "secret", + }, + { + desc: "missing API key", + apiKey: "", + secret: "secret", + expected: "active24: credentials missing", + }, + { + desc: "missing secret", + apiKey: "user", + secret: "", + expected: "active24: credentials missing", + }, + { + desc: "missing credentials", + expected: "active24: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + config.Secret = test.secret + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.prv) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/alidns/alidns.go b/providers/dns/alidns/alidns.go index 803567e1d..cdd8e75e0 100644 --- a/providers/dns/alidns/alidns.go +++ b/providers/dns/alidns/alidns.go @@ -2,22 +2,22 @@ package alidns import ( + "context" "errors" "fmt" "time" - "github.com/aliyun/alibaba-cloud-sdk-go/sdk" - "github.com/aliyun/alibaba-cloud-sdk-go/sdk/auth" - "github.com/aliyun/alibaba-cloud-sdk-go/sdk/auth/credentials" - "github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests" - "github.com/aliyun/alibaba-cloud-sdk-go/services/alidns" + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + "github.com/alibabacloud-go/tea/dara" + "github.com/aliyun/credentials-go/credentials" + alidns "github.com/go-acme/alidns-20150109/v4/client" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" "golang.org/x/net/idna" ) -const defaultRegionID = "cn-hangzhou" - // Environment variables names. const ( envNamespace = "ALICLOUD_" @@ -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" @@ -34,6 +35,10 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const defaultRegionID = "cn-hangzhou" + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { RAMRole string @@ -41,6 +46,7 @@ type Config struct { SecretKey string SecurityToken string RegionID string + Line string PropagationTimeout time.Duration PollingInterval time.Duration TTL int @@ -70,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 { @@ -99,23 +106,42 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { config.RegionID = defaultRegionID } - var credential auth.Credential + cfg := new(openapi.Config). + SetRegionId(config.RegionID). + SetReadTimeout(int(config.HTTPTimeout.Milliseconds())) + switch { case config.RAMRole != "": - credential = credentials.NewEcsRamRoleCredential(config.RAMRole) + // https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance + credentialsCfg := new(credentials.Config). + SetType("ecs_ram_role"). + SetRoleName(config.RAMRole) + + credentialClient, err := credentials.NewCredential(credentialsCfg) + if err != nil { + return nil, fmt.Errorf("alicloud: new credential: %w", err) + } + + cfg = cfg.SetCredential(credentialClient) + case config.APIKey != "" && config.SecretKey != "" && config.SecurityToken != "": - credential = credentials.NewStsTokenCredential(config.APIKey, config.SecretKey, config.SecurityToken) + cfg = cfg. + SetAccessKeyId(config.APIKey). + SetAccessKeySecret(config.SecretKey). + SetSecurityToken(config.SecurityToken) + case config.APIKey != "" && config.SecretKey != "": - credential = credentials.NewAccessKeyCredential(config.APIKey, config.SecretKey) + cfg = cfg. + SetAccessKeyId(config.APIKey). + SetAccessKeySecret(config.SecretKey) + default: return nil, errors.New("alicloud: ram role or credentials missing") } - conf := sdk.NewConfig().WithTimeout(config.HTTPTimeout) - - client, err := alidns.NewClientWithOptions(config.RegionID, conf, credential) + client, err := alidns.NewClient(cfg) if err != nil { - return nil, fmt.Errorf("alicloud: credentials failed: %w", err) + return nil, fmt.Errorf("alicloud: new client: %w", err) } return &DNSProvider{config: config, client: client}, nil @@ -129,67 +155,76 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) - zoneName, err := d.getHostedZone(info.EffectiveFQDN) + zoneName, err := d.getHostedZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("alicloud: %w", err) } - recordAttributes, err := d.newTxtRecord(zoneName, info.EffectiveFQDN, info.Value) + recordRequest, err := d.newTxtRecord(zoneName, info.EffectiveFQDN, info.Value) if err != nil { return err } - _, err = d.client.AddDomainRecord(recordAttributes) + _, err = alidns.AddDomainRecordWithContext(ctx, d.client, recordRequest, &dara.RuntimeOptions{}) if err != nil { return fmt.Errorf("alicloud: API call failed: %w", err) } + return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) - records, err := d.findTxtRecords(info.EffectiveFQDN) + records, err := d.findTxtRecords(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("alicloud: %w", err) } - _, err = d.getHostedZone(info.EffectiveFQDN) + _, err = d.getHostedZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("alicloud: %w", err) } for _, rec := range records { - request := alidns.CreateDeleteDomainRecordRequest() - request.RecordId = rec.RecordId - _, err = d.client.DeleteDomainRecord(request) + request := &alidns.DeleteDomainRecordRequest{ + RecordId: rec.RecordId, + } + + _, err = alidns.DeleteDomainRecordWithContext(ctx, d.client, request, &dara.RuntimeOptions{}) if err != nil { return fmt.Errorf("alicloud: %w", err) } } + return nil } -func (d *DNSProvider) getHostedZone(domain string) (string, error) { - request := alidns.CreateDescribeDomainsRequest() +func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (string, error) { + request := new(alidns.DescribeDomainsRequest) - var domains []alidns.DomainInDescribeDomains - startPage := 1 + var domains []*alidns.DescribeDomainsResponseBodyDomainsDomain + + var startPage int64 = 1 for { - request.PageNumber = requests.NewInteger(startPage) + request.SetPageNumber(startPage) - response, err := d.client.DescribeDomains(request) + response, err := alidns.DescribeDomainsWithContext(ctx, d.client, request, &dara.RuntimeOptions{}) if err != nil { return "", fmt.Errorf("API call failed: %w", err) } - domains = append(domains, response.Domains.Domain...) + domains = append(domains, response.Body.Domains.Domain...) - if response.PageNumber*response.PageSize >= response.TotalCount { + if ptr.Deref(response.Body.PageNumber)*ptr.Deref(response.Body.PageSize) >= ptr.Deref(response.Body.TotalCount) { break } @@ -201,50 +236,54 @@ func (d *DNSProvider) getHostedZone(domain string) (string, error) { return "", fmt.Errorf("could not find zone: %w", err) } - var hostedZone alidns.DomainInDescribeDomains + var hostedZone *alidns.DescribeDomainsResponseBodyDomainsDomain + for _, zone := range domains { - if zone.DomainName == dns01.UnFqdn(authZone) || zone.PunyCode == dns01.UnFqdn(authZone) { + if ptr.Deref(zone.DomainName) == dns01.UnFqdn(authZone) || ptr.Deref(zone.PunyCode) == dns01.UnFqdn(authZone) { hostedZone = zone } } - if hostedZone.DomainId == "" { + if hostedZone == nil || ptr.Deref(hostedZone.DomainId) == "" { return "", fmt.Errorf("zone %s not found in AliDNS for domain %s", authZone, domain) } - return hostedZone.DomainName, nil + return ptr.Deref(hostedZone.DomainName), nil } func (d *DNSProvider) newTxtRecord(zone, fqdn, value string) (*alidns.AddDomainRecordRequest, error) { - request := alidns.CreateAddDomainRecordRequest() - request.Type = "TXT" - request.DomainName = zone - - var err error - request.RR, err = extractRecordName(fqdn, zone) + rr, err := extractRecordName(fqdn, zone) if err != nil { return nil, err } - request.Value = value - request.TTL = requests.NewInteger(d.config.TTL) + adrr := new(alidns.AddDomainRecordRequest). + SetType("TXT"). + SetDomainName(zone). + SetRR(rr). + SetValue(value). + SetTTL(int64(d.config.TTL)) - return request, nil + if d.config.Line != "" { + adrr.SetLine(d.config.Line) + } + + return adrr, nil } -func (d *DNSProvider) findTxtRecords(fqdn string) ([]alidns.Record, error) { - zoneName, err := d.getHostedZone(fqdn) +func (d *DNSProvider) findTxtRecords(ctx context.Context, fqdn string) ([]*alidns.DescribeDomainRecordsResponseBodyDomainRecordsRecord, error) { + zoneName, err := d.getHostedZone(ctx, fqdn) if err != nil { return nil, err } - request := alidns.CreateDescribeDomainRecordsRequest() - request.DomainName = zoneName - request.PageSize = requests.NewInteger(500) + request := new(alidns.DescribeDomainRecordsRequest). + SetDomainName(zoneName). + SetPageSize(500) - var records []alidns.Record + var records []*alidns.DescribeDomainRecordsResponseBodyDomainRecordsRecord - result, err := d.client.DescribeDomainRecords(request) + result, err := alidns.DescribeDomainRecordsWithContext(ctx, d.client, request, &dara.RuntimeOptions{}) if err != nil { return records, fmt.Errorf("API call has failed: %w", err) } @@ -254,11 +293,12 @@ func (d *DNSProvider) findTxtRecords(fqdn string) ([]alidns.Record, error) { return nil, err } - for _, record := range result.DomainRecords.Record { - if record.RR == recordName && record.Type == "TXT" { + for _, record := range result.Body.DomainRecords.Record { + if ptr.Deref(record.RR) == recordName && ptr.Deref(record.Type) == "TXT" { records = append(records, record) } } + return records, nil } diff --git a/providers/dns/alidns/alidns.toml b/providers/dns/alidns/alidns.toml index e2d5af8f8..b78e1859d 100644 --- a/providers/dns/alidns/alidns.toml +++ b/providers/dns/alidns/alidns.toml @@ -7,27 +7,30 @@ Since = "v1.1.0" Example = ''' # Setup using instance RAM role ALICLOUD_RAM_ROLE=lego \ -lego --email you@example.com --dns alidns -d '*.example.com' -d example.com run +lego --dns alidns -d '*.example.com' -d example.com run # Or, using credentials ALICLOUD_ACCESS_KEY=abcdefghijklmnopqrstuvwx \ ALICLOUD_SECRET_KEY=your-secret-key \ ALICLOUD_SECURITY_TOKEN=your-sts-token \ -lego --email you@example.com --dns alidns - -d '*.example.com' -d example.com run +lego --dns alidns - -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] - ALICLOUD_RAM_ROLE = "Your instance RAM role (https://www.alibabacloud.com/help/doc-detail/54579.htm)" + ALICLOUD_RAM_ROLE = "Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance)" ALICLOUD_ACCESS_KEY = "Access key ID" ALICLOUD_SECRET_KEY = "Access Key secret" ALICLOUD_SECURITY_TOKEN = "STS Security Token (optional)" [Configuration.Additional] - ALICLOUD_POLLING_INTERVAL = "Time between DNS propagation check" - ALICLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - ALICLOUD_TTL = "The TTL of the TXT record used for the DNS challenge" - ALICLOUD_HTTP_TIMEOUT = "API request timeout" + ALICLOUD_REGION_ID = "Region ID (Default: cn-hangzhou)" + ALICLOUD_LINE = "Line (Default: default)" + ALICLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + ALICLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + ALICLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" + ALICLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://www.alibabacloud.com/help/en/alibaba-cloud-dns/latest/api-alidns-2015-01-09-dir-parsing-records" - GoClient = "https://github.com/aliyun/alibaba-cloud-sdk-go" + GoClient = "https://github.com/alibabacloud-go/alidns-20150109" + GoClient2 = "https://github.com/aliyun/alibabacloud-go-sdk/tree/HEAD/alidns-20150109" diff --git a/providers/dns/alidns/alidns_test.go b/providers/dns/alidns/alidns_test.go index 487997813..b1e482d2d 100644 --- a/providers/dns/alidns/alidns_test.go +++ b/providers/dns/alidns/alidns_test.go @@ -64,6 +64,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -142,6 +143,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -155,6 +157,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/aliesa/aliesa.go b/providers/dns/aliesa/aliesa.go new file mode 100644 index 000000000..2a38389be --- /dev/null +++ b/providers/dns/aliesa/aliesa.go @@ -0,0 +1,255 @@ +// Package aliesa implements a DNS provider for solving the DNS-01 challenge using AlibabaCloud ESA. +package aliesa + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + "github.com/alibabacloud-go/tea/dara" + "github.com/aliyun/credentials-go/credentials" + esa "github.com/go-acme/esa-20240910/v2/client" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" +) + +// Environment variables names. +const ( + envNamespace = "ALIESA_" + + EnvRAMRole = envNamespace + "RAM_ROLE" + EnvAccessKey = envNamespace + "ACCESS_KEY" + EnvSecretKey = envNamespace + "SECRET_KEY" + EnvSecurityToken = envNamespace + "SECURITY_TOKEN" + EnvRegionID = envNamespace + "REGION_ID" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +const defaultRegionID = "cn-hangzhou" + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + RAMRole string + APIKey string + SecretKey string + SecurityToken string + RegionID string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPTimeout time.Duration +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *esa.Client + + recordIDs map[string]int64 + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for AlibabaCloud ESA. +func NewDNSProvider() (*DNSProvider, error) { + config := NewDefaultConfig() + config.RegionID = env.GetOrFile(EnvRegionID) + + values, err := env.Get(EnvRAMRole) + if err == nil { + config.RAMRole = values[EnvRAMRole] + return NewDNSProviderConfig(config) + } + + values, err = env.Get(EnvAccessKey, EnvSecretKey) + if err != nil { + return nil, fmt.Errorf("aliesa: %w", err) + } + + config.APIKey = values[EnvAccessKey] + config.SecretKey = values[EnvSecretKey] + config.SecurityToken = env.GetOrFile(EnvSecurityToken) + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for AlibabaCloud ESA. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("aliesa: the configuration of the DNS provider is nil") + } + + if config.RegionID == "" { + config.RegionID = defaultRegionID + } + + cfg := new(openapi.Config). + SetRegionId(config.RegionID). + SetReadTimeout(int(config.HTTPTimeout.Milliseconds())) + + switch { + case config.RAMRole != "": + // https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance + credentialsCfg := new(credentials.Config). + SetType("ecs_ram_role"). + SetRoleName(config.RAMRole) + + credentialClient, err := credentials.NewCredential(credentialsCfg) + if err != nil { + return nil, fmt.Errorf("aliesa: new credential: %w", err) + } + + cfg = cfg.SetCredential(credentialClient) + + case config.APIKey != "" && config.SecretKey != "" && config.SecurityToken != "": + cfg = cfg. + SetAccessKeyId(config.APIKey). + SetAccessKeySecret(config.SecretKey). + SetSecurityToken(config.SecurityToken) + + case config.APIKey != "" && config.SecretKey != "": + cfg = cfg. + SetAccessKeyId(config.APIKey). + SetAccessKeySecret(config.SecretKey) + + default: + return nil, errors.New("aliesa: ram role or credentials missing") + } + + client, err := esa.NewClient(cfg) + if err != nil { + return nil, fmt.Errorf("aliesa: new client: %w", err) + } + + // Workaround to get a regional URL. + // https://github.com/alibabacloud-go/esa-20240910/blame/7660e3aab2045d4820e4b83427a154efe0c79319/client/client.go#L27 + // The `EndpointRule` is hardcoded with an empty string, so the region is ignored. + client.Endpoint = nil + client.EndpointRule = ptr.Pointer("regional") + + client.Endpoint, err = esa.GetEndpoint(client, dara.String("esa"), client.RegionId, client.EndpointRule, client.Network, client.Suffix, client.EndpointMap, client.Endpoint) + if err != nil { + return nil, fmt.Errorf("aliesa: get endpoint: %w", err) + } + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]int64), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + siteID, err := d.getSiteID(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("aliesa: %w", err) + } + + crReq := new(esa.CreateRecordRequest). + SetSiteId(siteID). + SetType("TXT"). + SetRecordName(dns01.UnFqdn(info.EffectiveFQDN)). + SetTtl(int32(d.config.TTL)). + SetData(new(esa.CreateRecordRequestData).SetValue(info.Value)) + + // https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-createrecord + crResp, err := esa.CreateRecordWithContext(ctx, d.client, crReq, &dara.RuntimeOptions{}) + if err != nil { + return fmt.Errorf("aliesa: create record: %w", err) + } + + d.recordIDsMu.Lock() + d.recordIDs[token] = ptr.Deref(crResp.Body.GetRecordId()) + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + // gets the record's unique ID + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + + if !ok { + return fmt.Errorf("aliesa: unknown record ID for '%s'", info.EffectiveFQDN) + } + + drReq := new(esa.DeleteRecordRequest). + SetRecordId(recordID) + + // https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-deleterecord + _, err := esa.DeleteRecordWithContext(ctx, d.client, drReq, &dara.RuntimeOptions{}) + if err != nil { + return fmt.Errorf("aliesa: delete record: %w", err) + } + + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) getSiteID(ctx context.Context, fqdn string) (int64, error) { + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return 0, fmt.Errorf("aliesa: could not find zone for domain %q: %w", fqdn, err) + } + + lsReq := new(esa.ListSitesRequest). + SetSiteName(dns01.UnFqdn(authZone)). + SetSiteSearchType("suffix") + + // https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-listsites + lsResp, err := esa.ListSitesWithContext(ctx, d.client, lsReq, &dara.RuntimeOptions{}) + if err != nil { + return 0, fmt.Errorf("list sites: %w", err) + } + + for f := range dns01.UnFqdnDomainsSeq(fqdn) { + domain := dns01.UnFqdn(f) + + for _, site := range lsResp.Body.GetSites() { + if ptr.Deref(site.GetSiteName()) == domain { + return ptr.Deref(site.GetSiteId()), nil + } + } + } + + return 0, fmt.Errorf("site not found (fqdn: %q)", fqdn) +} diff --git a/providers/dns/aliesa/aliesa.toml b/providers/dns/aliesa/aliesa.toml new file mode 100644 index 000000000..5e7345e40 --- /dev/null +++ b/providers/dns/aliesa/aliesa.toml @@ -0,0 +1,33 @@ +Name = "AlibabaCloud ESA" +Description = '''''' +URL = "https://www.alibabacloud.com/en/product/esa" +Code = "aliesa" +Since = "v4.29.0" + +Example = ''' +# Setup using instance RAM role +ALIESA_RAM_ROLE=lego \ +lego --dns aliesa -d '*.example.com' -d example.com run + +# Or, using credentials +ALIESA_ACCESS_KEY=abcdefghijklmnopqrstuvwx \ +ALIESA_SECRET_KEY=your-secret-key \ +ALIESA_SECURITY_TOKEN=your-sts-token \ +lego --dns aliesa - -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + ALIESA_RAM_ROLE = "Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance)" + ALIESA_ACCESS_KEY = "Access key ID" + ALIESA_SECRET_KEY = "Access Key secret" + ALIESA_SECURITY_TOKEN = "STS Security Token (optional)" + [Configuration.Additional] + ALIESA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + ALIESA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + ALIESA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + ALIESA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-overview?spm=a2c63.p38356.help-menu-2673927.d_6_0_0.20b224c28PSZDc#:~:text=DNS-,DNS%20records,-DNS%20records" + GoClient = "https://github.com/alibabacloud-go/esa-20240910" diff --git a/providers/dns/aliesa/aliesa_test.go b/providers/dns/aliesa/aliesa_test.go new file mode 100644 index 000000000..025529409 --- /dev/null +++ b/providers/dns/aliesa/aliesa_test.go @@ -0,0 +1,151 @@ +package aliesa + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvAccessKey, + EnvSecretKey, + EnvRAMRole). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAccessKey: "123", + EnvSecretKey: "456", + }, + }, + { + desc: "success (RAM role)", + envVars: map[string]string{ + EnvRAMRole: "LegoInstanceRole", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvAccessKey: "", + EnvSecretKey: "", + }, + expected: "aliesa: some credentials information are missing: ALIESA_ACCESS_KEY,ALIESA_SECRET_KEY", + }, + { + desc: "missing access key", + envVars: map[string]string{ + EnvAccessKey: "", + EnvSecretKey: "456", + }, + expected: "aliesa: some credentials information are missing: ALIESA_ACCESS_KEY", + }, + { + desc: "missing secret key", + envVars: map[string]string{ + EnvAccessKey: "123", + EnvSecretKey: "", + }, + expected: "aliesa: some credentials information are missing: ALIESA_SECRET_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + ramRole string + apiKey string + secretKey string + expected string + }{ + { + desc: "success", + apiKey: "123", + secretKey: "456", + }, + { + desc: "success", + ramRole: "LegoInstanceRole", + }, + { + desc: "missing credentials", + expected: "aliesa: ram role or credentials missing", + }, + { + desc: "missing api key", + secretKey: "456", + expected: "aliesa: ram role or credentials missing", + }, + { + desc: "missing secret key", + apiKey: "123", + expected: "aliesa: ram role or credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + config.SecretKey = test.secretKey + config.RAMRole = test.ramRole + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/allinkl/allinkl.go b/providers/dns/allinkl/allinkl.go index aaaca844c..376b0903c 100644 --- a/providers/dns/allinkl/allinkl.go +++ b/providers/dns/allinkl/allinkl.go @@ -9,9 +9,12 @@ import ( "sync" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/allinkl/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -26,6 +29,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Login string @@ -89,12 +94,16 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { identifier.HTTPClient = config.HTTPClient } + identifier.HTTPClient = clientdebug.Wrap(identifier.HTTPClient) + client := internal.NewClient(config.Login) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, identifier: identifier, @@ -113,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) @@ -141,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() @@ -159,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) @@ -168,14 +177,33 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("allinkl: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } _, err = d.client.DeleteDNSSettings(ctx, recordID) if err != nil { - return fmt.Errorf("allinkl: %w", err) + return fmt.Errorf("allinkl: delete DNS settings: %w", err) } + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + return nil } + +func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (string, error) { + for z := range dns01.DomainsSeq(fqdn) { + _, errG := d.client.GetDNSSettings(ctx, z, "") + if errG != nil { + log.Infof("get DNS settings zone[%q] %v", z, errG) + continue + } + + return z, nil + } + + return "", fmt.Errorf("unable to find auth zone for '%s'", fqdn) +} diff --git a/providers/dns/allinkl/allinkl.toml b/providers/dns/allinkl/allinkl.toml index 4a308d653..774f8fb9f 100644 --- a/providers/dns/allinkl/allinkl.toml +++ b/providers/dns/allinkl/allinkl.toml @@ -7,7 +7,7 @@ Since = "v4.5.0" Example = ''' ALL_INKL_LOGIN=xxxxxxxxxxxxxxxxxxxxxxxxxx \ ALL_INKL_PASSWORD=yyyyyyyyyyyyyyyyyyyyyyyyyy \ -lego --email you@example.com --dns allinkl -d '*.example.com' -d example.com run +lego --dns allinkl -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,9 +15,9 @@ lego --email you@example.com --dns allinkl -d '*.example.com' -d example.com run ALL_INKL_LOGIN = "KAS login" ALL_INKL_PASSWORD = "KAS password" [Configuration.Additional] - ALL_INKL_POLLING_INTERVAL = "Time between DNS propagation check" - ALL_INKL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - ALL_INKL_HTTP_TIMEOUT = "API request timeout" + ALL_INKL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + ALL_INKL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + ALL_INKL_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://kasapi.kasserver.com/dokumentation/phpdoc/index.html" diff --git a/providers/dns/allinkl/allinkl_test.go b/providers/dns/allinkl/allinkl_test.go index af85f8c54..7da47aee4 100644 --- a/providers/dns/allinkl/allinkl_test.go +++ b/providers/dns/allinkl/allinkl_test.go @@ -1,9 +1,18 @@ package allinkl import ( + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/go-acme/lego/v4/providers/dns/allinkl/internal" "github.com/stretchr/testify/require" ) @@ -53,6 +62,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -121,6 +131,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -134,9 +145,115 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Login = "user" + config.Password = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + p.identifier.BaseURL, _ = url.Parse(server.URL) + + return p, err + }, + ).Route("POST /KasAuth.php", + servermock.ResponseFromInternal("auth.xml"), + servermock.CheckRequestBodyFromInternal("auth-request.xml"). + IgnoreWhitespace(), + ) +} + +func extractKasRequest(reader io.Reader) (*internal.KasRequest, error) { + type ReqEnvelope struct { + XMLName xml.Name `xml:"Envelope"` + Body struct { + KasAPI struct { + Params string `xml:"Params"` + } `xml:"KasApi"` + } `xml:"Body"` + } + + raw, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + reqEnvelope := ReqEnvelope{} + + err = xml.Unmarshal(raw, &reqEnvelope) + if err != nil { + return nil, err + } + + var kReq internal.KasRequest + + err = json.Unmarshal([]byte(reqEnvelope.Body.KasAPI.Params), &kReq) + if err != nil { + return nil, err + } + + return &kReq, nil +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("POST /KasApi.php", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + kReq, err := extractKasRequest(req.Body) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + switch kReq.Action { + case "get_dns_settings": + params := kReq.RequestParams.(map[string]any) + + if params["zone_host"] == "_acme-challenge.example.com." { + servermock.ResponseFromInternal("get_dns_settings_not_found.xml").ServeHTTP(rw, req) + } else { + servermock.ResponseFromInternal("get_dns_settings.xml").ServeHTTP(rw, req) + } + + case "add_dns_settings": + servermock.ResponseFromInternal("add_dns_settings.xml").ServeHTTP(rw, req) + + default: + http.Error(rw, fmt.Sprintf("unknown action: %v", kReq.Action), http.StatusBadRequest) + } + }), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("POST /KasApi.php", + servermock.ResponseFromInternal("delete_dns_settings.xml"), + servermock.CheckRequestBodyFromInternal("delete_dns_settings-request.xml"). + IgnoreWhitespace()). + Build(t) + + provider.recordIDs["abc"] = "57347450" + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/allinkl/internal/client.go b/providers/dns/allinkl/internal/client.go index ab8cf9a38..d4403cac5 100644 --- a/providers/dns/allinkl/internal/client.go +++ b/providers/dns/allinkl/internal/client.go @@ -6,16 +6,21 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strconv" "strings" "sync" "time" + "github.com/cenkalti/backoff/v5" + "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-viper/mapstructure/v2" ) -const apiEndpoint = "https://kasapi.kasserver.com/soap/KasApi.php" +const defaultBaseURL = "https://kasapi.kasserver.com/soap/" + +const apiPath = "KasApi.php" type Authentication interface { Authentication(ctx context.Context, sessionLifetime int, sessionUpdateLifetime bool) (string, error) @@ -28,16 +33,21 @@ type Client struct { floodTime time.Time muFloodTime sync.Mutex - baseURL string + maxElapsedTime time.Duration + + BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(login string) *Client { + baseURL, _ := url.Parse(defaultBaseURL) + return &Client{ - login: login, - baseURL: apiEndpoint, - HTTPClient: &http.Client{Timeout: 10 * time.Second}, + login: login, + BaseURL: baseURL, + maxElapsedTime: 3 * time.Minute, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } @@ -51,13 +61,9 @@ func (c *Client) GetDNSSettings(ctx context.Context, zone, recordID string) ([]R requestParams["record_id"] = recordID } - req, err := c.newRequest(ctx, "get_dns_settings", requestParams) - if err != nil { - return nil, err - } + var g APIResponse[GetDNSSettingsResponse] - var g GetDNSSettingsAPIResponse - err = c.do(req, &g) + err := c.doRequest(ctx, "get_dns_settings", requestParams, &g) if err != nil { return nil, err } @@ -69,13 +75,9 @@ func (c *Client) GetDNSSettings(ctx context.Context, zone, recordID string) ([]R // AddDNSSettings Creation of a DNS resource record. func (c *Client) AddDNSSettings(ctx context.Context, record DNSRequest) (string, error) { - req, err := c.newRequest(ctx, "add_dns_settings", record) - if err != nil { - return "", err - } + var g APIResponse[AddDNSSettingsResponse] - var g AddDNSSettingsAPIResponse - err = c.do(req, &g) + err := c.doRequest(ctx, "add_dns_settings", record, &g) if err != nil { return "", err } @@ -86,23 +88,19 @@ func (c *Client) AddDNSSettings(ctx context.Context, record DNSRequest) (string, } // DeleteDNSSettings Deleting a DNS Resource Record. -func (c *Client) DeleteDNSSettings(ctx context.Context, recordID string) (bool, error) { +func (c *Client) DeleteDNSSettings(ctx context.Context, recordID string) (string, error) { requestParams := map[string]string{"record_id": recordID} - req, err := c.newRequest(ctx, "delete_dns_settings", requestParams) - if err != nil { - return false, err - } + var g APIResponse[DeleteDNSSettingsResponse] - var g DeleteDNSSettingsAPIResponse - err = c.do(req, &g) + err := c.doRequest(ctx, "delete_dns_settings", requestParams, &g) if err != nil { - return false, err + return "", err } c.updateFloodTime(g.Response.KasFloodDelay) - return g.Response.ReturnInfo, nil + return g.Response.ReturnString, nil } func (c *Client) newRequest(ctx context.Context, action string, requestParams any) (*http.Request, error) { @@ -121,7 +119,9 @@ func (c *Client) newRequest(ctx context.Context, action string, requestParams an payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAPIEnvelope, body))) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(payload)) + endpoint := c.BaseURL.JoinPath(apiPath) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } @@ -129,6 +129,21 @@ func (c *Client) newRequest(ctx context.Context, action string, requestParams an return req, nil } +func (c *Client) doRequest(ctx context.Context, action string, requestParams, result any) error { + return wait.Retry(ctx, + func() error { + req, err := c.newRequest(ctx, action, requestParams) + if err != nil { + return backoff.Permanent(err) + } + + return c.do(req, result) + }, + backoff.WithBackOff(&backoff.ZeroBackOff{}), + backoff.WithMaxElapsedTime(c.maxElapsedTime), + ) +} + func (c *Client) do(req *http.Request, result any) error { c.muFloodTime.Lock() time.Sleep(time.Until(c.floodTime)) @@ -136,29 +151,40 @@ func (c *Client) do(req *http.Request, result any) error { resp, err := c.HTTPClient.Do(req) if err != nil { - return errutils.NewHTTPDoError(req, err) + return backoff.Permanent(errutils.NewHTTPDoError(req, err)) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return errutils.NewUnexpectedResponseStatusCodeError(req, resp) + return backoff.Permanent(errutils.NewUnexpectedResponseStatusCodeError(req, resp)) } envlp, err := decodeXML[KasAPIResponseEnvelope](resp.Body) if err != nil { - return err + return backoff.Permanent(err) } if envlp.Body.Fault != nil { - return envlp.Body.Fault + if envlp.Body.Fault.Message == "flood_protection" { + ft, errP := strconv.ParseFloat(envlp.Body.Fault.Detail, 64) + if errP != nil { + return fmt.Errorf("parse flood protection delay: %w", envlp.Body.Fault) + } + + c.updateFloodTime(ft) + + return envlp.Body.Fault + } + + return backoff.Permanent(envlp.Body.Fault) } raw := getValue(envlp.Body.KasAPIResponse.Return) err = mapstructure.Decode(raw, result) if err != nil { - return fmt.Errorf("response struct decode: %w", err) + return backoff.Permanent(fmt.Errorf("response struct decode: %w", err)) } return nil diff --git a/providers/dns/allinkl/internal/client_test.go b/providers/dns/allinkl/internal/client_test.go index 3eb7c21a9..949f45bf9 100644 --- a/providers/dns/allinkl/internal/client_test.go +++ b/providers/dns/allinkl/internal/client_test.go @@ -1,29 +1,34 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" + "net/url" "testing" + "time" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestClient_GetDNSSettings(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", testHandler("get_dns_settings.xml")) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("user") - client.baseURL = server.URL + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - records, err := client.GetDNSSettings(mockContext(), "example.com", "") + client.maxElapsedTime = 1 * time.Second + + return client, nil +} + +func TestClient_GetDNSSettings(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /KasApi.php", servermock.ResponseFromFixture("get_dns_settings.xml"), + servermock.CheckRequestBodyFromFixture("get_dns_settings-request.xml"). + IgnoreWhitespace()). + Build(t) + + records, err := client.GetDNSSettings(mockContext(t), "example.com", "") require.NoError(t, err) expected := []ReturnInfo{ @@ -95,15 +100,27 @@ func TestClient_GetDNSSettings(t *testing.T) { assert.Equal(t, expected, records) } +func TestClient_GetDNSSettings_error_flood_protection(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /KasApi.php", + servermock.ResponseFromFixture("flood_protection.xml"), + ). + Build(t) + + assert.Zero(t, client.floodTime) + + _, err := client.GetDNSSettings(mockContext(t), "example.com", "") + require.EqualError(t, err, "KasApi: SOAP-ENV:Server: flood_protection: 0.0688529014587") + + assert.NotZero(t, client.floodTime) +} + func TestClient_AddDNSSettings(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", testHandler("add_dns_settings.xml")) - - client := NewClient("user") - client.baseURL = server.URL + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /KasApi.php", servermock.ResponseFromFixture("add_dns_settings.xml"), + servermock.CheckRequestBodyFromFixture("add_dns_settings-request.xml"). + IgnoreWhitespace()). + Build(t) record := DNSRequest{ ZoneHost: "42cnc.de.", @@ -112,47 +129,21 @@ func TestClient_AddDNSSettings(t *testing.T) { RecordData: "abcdefgh", } - recordID, err := client.AddDNSSettings(mockContext(), record) + recordID, err := client.AddDNSSettings(mockContext(t), record) require.NoError(t, err) assert.Equal(t, "57347444", recordID) } func TestClient_DeleteDNSSettings(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /KasApi.php", servermock.ResponseFromFixture("delete_dns_settings.xml"), + servermock.CheckRequestBodyFromFixture("delete_dns_settings-request.xml"). + IgnoreWhitespace()). + Build(t) - mux.HandleFunc("/", testHandler("delete_dns_settings.xml")) - - client := NewClient("user") - client.baseURL = server.URL - - r, err := client.DeleteDNSSettings(mockContext(), "57347450") + r, err := client.DeleteDNSSettings(mockContext(t), "57347450") require.NoError(t, err) - assert.True(t, r) -} - -func testHandler(filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } + assert.Equal(t, "TRUE", r) } diff --git a/providers/dns/allinkl/internal/fixtures/add_dns_settings-request.xml b/providers/dns/allinkl/internal/fixtures/add_dns_settings-request.xml new file mode 100644 index 000000000..e8cd12633 --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/add_dns_settings-request.xml @@ -0,0 +1,7 @@ + + + + {"kas_login":"user","kas_auth_type":"session","kas_auth_data":"593959ca04f0de9689b586c6a647d15d","kas_action":"add_dns_settings","KasRequestParams":{"zone_host":"42cnc.de.","record_type":"TXT","record_name":"lego","record_data":"abcdefgh","record_aux":0}} + + + diff --git a/providers/dns/allinkl/internal/fixtures/auth-request.xml b/providers/dns/allinkl/internal/fixtures/auth-request.xml new file mode 100644 index 000000000..1cba86f10 --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/auth-request.xml @@ -0,0 +1,7 @@ + + + + {"kas_login":"user","kas_auth_data":"secret","kas_auth_type":"plain","session_lifetime":60,"session_update_lifetime":"Y"} + + + diff --git a/providers/dns/allinkl/internal/fixtures/delete_dns_settings-request.xml b/providers/dns/allinkl/internal/fixtures/delete_dns_settings-request.xml new file mode 100644 index 000000000..a306a98a7 --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/delete_dns_settings-request.xml @@ -0,0 +1,7 @@ + + + + {"kas_login":"user","kas_auth_type":"session","kas_auth_data":"593959ca04f0de9689b586c6a647d15d","kas_action":"delete_dns_settings","KasRequestParams":{"record_id":"57347450"}} + + + diff --git a/providers/dns/allinkl/internal/fixtures/flood_protection.xml b/providers/dns/allinkl/internal/fixtures/flood_protection.xml new file mode 100644 index 000000000..b8da10fab --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/flood_protection.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + flood_protection + KasApi + 0.0688529014587 + + + diff --git a/providers/dns/allinkl/internal/fixtures/get_dns_settings-request.xml b/providers/dns/allinkl/internal/fixtures/get_dns_settings-request.xml new file mode 100644 index 000000000..b44941d2b --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/get_dns_settings-request.xml @@ -0,0 +1,7 @@ + + + + {"kas_login":"user","kas_auth_type":"session","kas_auth_data":"593959ca04f0de9689b586c6a647d15d","kas_action":"get_dns_settings","KasRequestParams":{"zone_host":"example.com"}} + + + diff --git a/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_not_found.xml b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_not_found.xml new file mode 100644 index 000000000..478d07a3a --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_not_found.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + zone_not_found + KasApi + example.com + + + diff --git a/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_syntax_incorrect.xml b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_syntax_incorrect.xml new file mode 100644 index 000000000..c77d733db --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + zone_syntax_incorrect + KasApi + _acme-challenge.example.com + + + diff --git a/providers/dns/allinkl/internal/identity.go b/providers/dns/allinkl/internal/identity.go index 4353ece31..e95e78899 100644 --- a/providers/dns/allinkl/internal/identity.go +++ b/providers/dns/allinkl/internal/identity.go @@ -6,14 +6,14 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) -// authEndpoint represents the Identity API endpoint to call. -const authEndpoint = "https://kasapi.kasserver.com/soap/KasAuth.php" +const authPath = "KasAuth.php" type token string @@ -24,17 +24,19 @@ type Identifier struct { login string password string - authEndpoint string - HTTPClient *http.Client + BaseURL *url.URL + HTTPClient *http.Client } // NewIdentifier creates a new Identifier. -func NewIdentifier(login string, password string) *Identifier { +func NewIdentifier(login, password string) *Identifier { + baseURL, _ := url.Parse(defaultBaseURL) + return &Identifier{ - login: login, - password: password, - authEndpoint: authEndpoint, - HTTPClient: &http.Client{Timeout: 10 * time.Second}, + login: login, + password: password, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } @@ -62,7 +64,9 @@ func (c *Identifier) Authentication(ctx context.Context, sessionLifetime int, se payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAuthEnvelope, body))) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authEndpoint, bytes.NewReader(payload)) + endpoint := c.BaseURL.JoinPath(authPath) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) if err != nil { return "", fmt.Errorf("unable to create request: %w", err) } diff --git a/providers/dns/allinkl/internal/identity_test.go b/providers/dns/allinkl/internal/identity_test.go index 0753f3862..41d092b13 100644 --- a/providers/dns/allinkl/internal/identity_test.go +++ b/providers/dns/allinkl/internal/identity_test.go @@ -2,44 +2,48 @@ package internal import ( "context" - "net/http" "net/http/httptest" + "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func mockContext() context.Context { - return context.WithValue(context.Background(), tokenKey, "593959ca04f0de9689b586c6a647d15d") +func setupIdentifierClient(server *httptest.Server) (*Identifier, error) { + client := NewIdentifier("user", "secret") + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil +} + +func mockContext(t *testing.T) context.Context { + t.Helper() + + return context.WithValue(t.Context(), tokenKey, "593959ca04f0de9689b586c6a647d15d") } func TestIdentifier_Authentication(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client := servermock.NewBuilder[*Identifier](setupIdentifierClient). + Route("POST /KasAuth.php", + servermock.ResponseFromFixture("auth.xml"), + servermock.CheckRequestBodyFromFixture("auth-request.xml"). + IgnoreWhitespace()). + Build(t) - mux.HandleFunc("/", testHandler("auth.xml")) - - client := NewIdentifier("user", "secret") - client.authEndpoint = server.URL - - credentialToken, err := client.Authentication(context.Background(), 60, false) + credentialToken, err := client.Authentication(t.Context(), 60, true) require.NoError(t, err) assert.Equal(t, "593959ca04f0de9689b586c6a647d15d", credentialToken) } func TestIdentifier_Authentication_error(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client := servermock.NewBuilder[*Identifier](setupIdentifierClient). + Route("POST /KasAuth.php", servermock.ResponseFromFixture("auth_fault.xml")). + Build(t) - mux.HandleFunc("/", testHandler("auth_fault.xml")) - - client := NewIdentifier("user", "secret") - client.authEndpoint = server.URL - - _, err := client.Authentication(context.Background(), 60, false) + _, err := client.Authentication(t.Context(), 60, false) require.Error(t, err) } diff --git a/providers/dns/allinkl/internal/types.go b/providers/dns/allinkl/internal/types.go index b5c6ba0d1..51f7065b5 100644 --- a/providers/dns/allinkl/internal/types.go +++ b/providers/dns/allinkl/internal/types.go @@ -17,6 +17,7 @@ func (tr Trimmer) Token() (xml.Token, error) { if cd, ok := t.(xml.CharData); ok { t = xml.CharData(bytes.TrimSpace(cd)) } + return t, err } @@ -25,10 +26,11 @@ type Fault struct { Code string `xml:"faultcode"` Message string `xml:"faultstring"` Actor string `xml:"faultactor"` + Detail string `xml:"detail"` } -func (f Fault) Error() string { - return fmt.Sprintf("%s: %s: %s", f.Actor, f.Code, f.Message) +func (f *Fault) Error() string { + return fmt.Sprintf("%s: %s: %s: %s", f.Actor, f.Code, f.Message, f.Detail) } // KasResponse a KAS SOAP response. @@ -53,6 +55,7 @@ func decodeXML[T any](reader io.Reader) (*T, error) { } var result T + err = xml.NewTokenDecoder(Trimmer{decoder: xml.NewDecoder(bytes.NewReader(raw))}).Decode(&result) if err != nil { return nil, fmt.Errorf("decode XML response: %w", err) diff --git a/providers/dns/allinkl/internal/types_api.go b/providers/dns/allinkl/internal/types_api.go index 145163cda..a11f3aac0 100644 --- a/providers/dns/allinkl/internal/types_api.go +++ b/providers/dns/allinkl/internal/types_api.go @@ -53,8 +53,8 @@ type DNSRequest struct { // --- -type GetDNSSettingsAPIResponse struct { - Response GetDNSSettingsResponse `json:"Response" mapstructure:"Response"` +type APIResponse[T any] struct { + Response T `json:"Response" mapstructure:"Response"` } type GetDNSSettingsResponse struct { @@ -73,22 +73,14 @@ type ReturnInfo struct { Aux int `json:"record_aux,omitempty" mapstructure:"record_aux"` } -type AddDNSSettingsAPIResponse struct { - Response AddDNSSettingsResponse `json:"Response" mapstructure:"Response"` -} - type AddDNSSettingsResponse struct { KasFloodDelay float64 `json:"KasFloodDelay" mapstructure:"KasFloodDelay"` ReturnInfo string `json:"ReturnInfo" mapstructure:"ReturnInfo"` ReturnString string `json:"ReturnString" mapstructure:"ReturnString"` } -type DeleteDNSSettingsAPIResponse struct { - Response DeleteDNSSettingsResponse `json:"Response"` -} - type DeleteDNSSettingsResponse struct { KasFloodDelay float64 `json:"KasFloodDelay"` - ReturnInfo bool `json:"ReturnInfo"` ReturnString string `json:"ReturnString"` + // NOTE: ReturnInfo (!= ReturnString) doesn't seem to have a stable type } diff --git a/providers/dns/alwaysdata/alwaysdata.go b/providers/dns/alwaysdata/alwaysdata.go new file mode 100644 index 000000000..b2e0f3957 --- /dev/null +++ b/providers/dns/alwaysdata/alwaysdata.go @@ -0,0 +1,185 @@ +// Package alwaysdata implements a DNS provider for solving the DNS-01 challenge using Alwaysdata. +package alwaysdata + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/alwaysdata/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "ALWAYSDATA_" + + EnvAPIKey = envNamespace + "API_KEY" + EnvAccount = envNamespace + "ACCOUNT" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIKey string + Account string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Alwaysdata. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("alwaysdata: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + config.Account = env.GetOrFile(EnvAccount) + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Alwaysdata. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("alwaysdata: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.APIKey, config.Account) + if err != nil { + return nil, fmt.Errorf("alwaysdata: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + zone, err := d.findZone(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("alwaysdata: %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name) + if err != nil { + return fmt.Errorf("alwaysdata: %w", err) + } + + record := internal.RecordRequest{ + DomainID: zone.ID, + Name: subDomain, + Type: "TXT", + Value: info.Value, + TTL: d.config.TTL, + Annotation: "lego", + } + + err = d.client.AddRecord(ctx, record) + if err != nil { + return fmt.Errorf("alwaysdata: add TXT record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + zone, err := d.findZone(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("alwaysdata: %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name) + if err != nil { + return fmt.Errorf("alwaysdata: %w", err) + } + + records, err := d.client.ListRecords(ctx, zone.ID, subDomain) + if err != nil { + return fmt.Errorf("alwaysdata: list records: %w", err) + } + + for _, record := range records { + if record.Type != "TXT" || record.Value != info.Value { + continue + } + + err = d.client.DeleteRecord(ctx, record.ID) + if err != nil { + return fmt.Errorf("alwaysdata: delete TXT record: %w", err) + } + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*internal.Domain, error) { + domains, err := d.client.ListDomains(ctx) + if err != nil { + return nil, fmt.Errorf("list domains: %w", err) + } + + for a := range dns01.UnFqdnDomainsSeq(fqdn) { + for _, domain := range domains { + if a == domain.Name { + return &domain, nil + } + } + } + + return nil, errors.New("domain not found") +} diff --git a/providers/dns/alwaysdata/alwaysdata.toml b/providers/dns/alwaysdata/alwaysdata.toml new file mode 100644 index 000000000..d00c6f032 --- /dev/null +++ b/providers/dns/alwaysdata/alwaysdata.toml @@ -0,0 +1,26 @@ +Name = "Alwaysdata" +Description = '''''' +URL = "https://alwaysdata.com/" +Code = "alwaysdata" +Since = "v4.31.0" + +Example = ''' +ALWAYSDATA_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns alwaysdata -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + ALWAYSDATA_API_KEY = "API Key" + [Configuration.Additional] + ALWAYSDATA_ACCOUNT = "Account name" + ALWAYSDATA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + ALWAYSDATA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + ALWAYSDATA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + ALWAYSDATA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://help.alwaysdata.com/en/api/resources/" + APIDocDomains = "https://api.alwaysdata.com/v1/domain/doc/" + APIDocRecords = "https://api.alwaysdata.com/v1/record/doc/" + APIExamples = "https://help.alwaysdata.com/en/api/examples/" diff --git a/providers/dns/alwaysdata/alwaysdata_test.go b/providers/dns/alwaysdata/alwaysdata_test.go new file mode 100644 index 000000000..6084c2ae4 --- /dev/null +++ b/providers/dns/alwaysdata/alwaysdata_test.go @@ -0,0 +1,187 @@ +package alwaysdata + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIKey, EnvAccount).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "secret", + }, + }, + { + desc: "success with an account", + envVars: map[string]string{ + EnvAPIKey: "secret", + EnvAccount: "foo", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "alwaysdata: some credentials information are missing: ALWAYSDATA_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + account string + expected string + }{ + { + desc: "success", + apiKey: "secret", + }, + { + desc: "success with an account", + apiKey: "secret", + account: "foo", + }, + { + desc: "missing credentials", + expected: "alwaysdata: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + config.Account = test.account + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIKey = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + WithBasicAuth("secret", ""), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("GET /domain/", + servermock.ResponseFromInternal("domains.json")). + Route("POST /record/", + servermock.Noop().WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromInternal("record_add-request.json")). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("GET /domain/", + servermock.ResponseFromInternal("domains.json")). + Route("GET /record/", + servermock.ResponseFromInternal("records.json"), + servermock.CheckQueryParameter().Strict(). + With("domain", "132"). + With("name", "_acme-challenge"), + ). + Route("DELETE /record/789/", + servermock.Noop()). + Build(t) + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/alwaysdata/internal/client.go b/providers/dns/alwaysdata/internal/client.go new file mode 100644 index 000000000..5db11dcd1 --- /dev/null +++ b/providers/dns/alwaysdata/internal/client.go @@ -0,0 +1,177 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://api.alwaysdata.com/v1" + +// Client the Alwaysdata API client. +type Client struct { + apiKey string + account string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(apiKey, account string) (*Client, error) { + if apiKey == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + apiKey: apiKey, + account: account, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { + endpoint := c.BaseURL.JoinPath("domain", "/") + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result []Domain + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result, nil +} + +func (c *Client) AddRecord(ctx context.Context, record RecordRequest) error { + endpoint := c.BaseURL.JoinPath("record", "/") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return err + } + + err = c.do(req, nil) + if err != nil { + return err + } + + return nil +} + +func (c *Client) DeleteRecord(ctx context.Context, recordID int64) error { + endpoint := c.BaseURL.JoinPath("record", strconv.FormatInt(recordID, 10), "/") + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) ListRecords(ctx context.Context, domainID int64, name string) ([]Record, error) { + endpoint := c.BaseURL.JoinPath("record", "/") + + query := endpoint.Query() + query.Set("domain", strconv.FormatInt(domainID, 10)) + query.Set("name", name) + endpoint.RawQuery = query.Encode() + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result []Record + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result, nil +} + +func (c *Client) do(req *http.Request, result any) error { + useragent.SetHeader(req.Header) + + user := c.apiKey + + if c.account != "" { + user += "account=" + c.account + } + + req.SetBasicAuth(user, "") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + raw, _ := io.ReadAll(resp.Body) + + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} diff --git a/providers/dns/alwaysdata/internal/client_test.go b/providers/dns/alwaysdata/internal/client_test.go new file mode 100644 index 000000000..e6a349662 --- /dev/null +++ b/providers/dns/alwaysdata/internal/client_test.go @@ -0,0 +1,124 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret", "") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = clientdebug.Wrap(server.Client()) + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + WithBasicAuth("secret", ""), + ) +} + +func TestClient_ListDomains(t *testing.T) { + client := mockBuilder(). + Route("GET /domain/", + servermock.ResponseFromFixture("domains.json")). + Build(t) + + result, err := client.ListDomains(t.Context()) + require.NoError(t, err) + + expected := []Domain{ + {ID: 132, Name: "example.com", Annotation: "test"}, + {ID: 133, Name: "example.net", IsInternal: true}, + {ID: 134, Name: "example.org"}, + } + + assert.Equal(t, expected, result) +} + +func TestClient_AddRecord(t *testing.T) { + t.Setenv("LEGO_DEBUG_DNS_API_HTTP_CLIENT", "true") + + client := mockBuilder(). + Route("POST /record/", + servermock.Noop().WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromFixture("record_add-request.json")). + Build(t) + + record := RecordRequest{ + DomainID: 132, + Name: "_acme-challenge", + Type: "TXT", + Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 120, + Annotation: "lego", + } + + err := client.AddRecord(t.Context(), record) + require.NoError(t, err) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /record/789/", + servermock.Noop()). + Build(t) + + err := client.DeleteRecord(t.Context(), 789) + require.NoError(t, err) +} + +func TestClient_ListRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /record/", + servermock.ResponseFromFixture("records.json"), + servermock.CheckQueryParameter().Strict(). + With("domain", "132"). + With("name", "_acme-challenge"), + ). + Build(t) + + result, err := client.ListRecords(t.Context(), 132, "_acme-challenge") + require.NoError(t, err) + + expected := []Record{ + { + ID: 789, + Domain: &Domain{ + Href: "/v1/domain/132/", + }, + Type: "TXT", + Name: "_acme-challenge", + Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 120, + Annotation: "lego", + }, + { + ID: 11619270, + Domain: &Domain{ + Href: "/v1/domain/118935/", + }, + Name: "home", + Type: "A", + Value: "149.202.90.65", + TTL: 300, + IsUserDefined: true, + IsActive: true, + }, + } + + assert.Equal(t, expected, result) +} diff --git a/providers/dns/alwaysdata/internal/fixtures/domains.json b/providers/dns/alwaysdata/internal/fixtures/domains.json new file mode 100644 index 000000000..dc34a948f --- /dev/null +++ b/providers/dns/alwaysdata/internal/fixtures/domains.json @@ -0,0 +1,16 @@ +[ + { + "id": 132, + "name": "example.com", + "annotation": "test" + }, + { + "id": 133, + "name": "example.net", + "is_internal": true + }, + { + "id": 134, + "name": "example.org" + } +] diff --git a/providers/dns/alwaysdata/internal/fixtures/record_add-request.json b/providers/dns/alwaysdata/internal/fixtures/record_add-request.json new file mode 100644 index 000000000..5b6db2646 --- /dev/null +++ b/providers/dns/alwaysdata/internal/fixtures/record_add-request.json @@ -0,0 +1,8 @@ +{ + "domain": 132, + "name": "_acme-challenge", + "type": "TXT", + "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 120, + "annotation": "lego" +} diff --git a/providers/dns/alwaysdata/internal/fixtures/records.json b/providers/dns/alwaysdata/internal/fixtures/records.json new file mode 100644 index 000000000..fa207395a --- /dev/null +++ b/providers/dns/alwaysdata/internal/fixtures/records.json @@ -0,0 +1,28 @@ +[ + { + "id": 789, + "domain": { + "href": "/v1/domain/132/" + }, + "name": "_acme-challenge", + "type": "TXT", + "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 120, + "annotation": "lego" + }, + { + "id": 11619270, + "domain": { + "href": "/v1/domain/118935/" + }, + "type": "A", + "name": "home", + "value": "149.202.90.65", + "priority": null, + "ttl": 300, + "href": "/v1/record/11619270/", + "annotation": "", + "is_user_defined": true, + "is_active": true + } +] diff --git a/providers/dns/alwaysdata/internal/types.go b/providers/dns/alwaysdata/internal/types.go new file mode 100644 index 000000000..b1e66fa5b --- /dev/null +++ b/providers/dns/alwaysdata/internal/types.go @@ -0,0 +1,33 @@ +package internal + +type RecordRequest struct { + ID int64 `json:"id,omitempty"` + DomainID int64 `json:"domain,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Value string `json:"value,omitempty"` + TTL int `json:"ttl,omitempty"` + Annotation string `json:"annotation,omitempty"` + IsUserDefined bool `json:"is_user_defined,omitempty"` + IsActive bool `json:"is_active,omitempty"` +} + +type Record struct { + ID int64 `json:"id,omitempty"` + Domain *Domain `json:"domain,omitempty"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` + TTL int `json:"ttl,omitempty"` + Annotation string `json:"annotation,omitempty"` + IsUserDefined bool `json:"is_user_defined,omitempty"` + IsActive bool `json:"is_active,omitempty"` +} + +type Domain struct { + ID int64 `json:"id,omitempty"` + Href string `json:"href,omitempty"` + Name string `json:"name,omitempty"` + IsInternal bool `json:"is_internal,omitempty"` + Annotation string `json:"annotation,omitempty"` +} diff --git a/providers/dns/anexia/anexia.go b/providers/dns/anexia/anexia.go new file mode 100644 index 000000000..3ce7e2208 --- /dev/null +++ b/providers/dns/anexia/anexia.go @@ -0,0 +1,237 @@ +// Package anexia implements a DNS provider for solving the DNS-01 challenge using Anexia CloudDNS. +package anexia + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/cenkalti/backoff/v5" + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/anexia/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "ANEXIA_" + + EnvToken = envNamespace + "TOKEN" + EnvAPIURL = envNamespace + "API_URL" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +const defaultTTL = 300 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Token string + APIURL string + + TTL int + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Anexia CloudDNS. +// Credentials must be passed in the environment variable: ANEXIA_TOKEN. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvToken) + if err != nil { + return nil, fmt.Errorf("anexia: %w", err) + } + + config := NewDefaultConfig() + config.Token = values[EnvToken] + config.APIURL = env.GetOrFile(EnvAPIURL) + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Anexia CloudDNS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("anexia: the configuration of the DNS provider is nil") + } + + if config.Token == "" { + return nil, errors.New("anexia: incomplete credentials, missing token") + } + + client, err := internal.NewClient(config.Token) + if err != nil { + return nil, fmt.Errorf("anexia: %w", err) + } + + if config.APIURL != "" { + var err error + + client.BaseURL, err = url.Parse(config.APIURL) + if err != nil { + return nil, fmt.Errorf("anexia: %w", err) + } + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("anexia: could not find zone for domain %q: %w", domain, err) + } + + recordName, err := extractRecordName(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("anexia: %w", err) + } + + zoneName := dns01.UnFqdn(authZone) + + recordReq := internal.Record{ + Name: recordName, + Type: "TXT", + RData: info.Value, + TTL: d.config.TTL, + } + + // Ignores returned zone, because of UUID unstability. + // https://github.com/go-acme/lego/pull/2675#issuecomment-3418678194 + _, err = d.client.CreateRecord(ctx, zoneName, recordReq) + if err != nil { + return fmt.Errorf("anexia: new record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("anexia: could not find zone for domain %q: %w", domain, err) + } + + recordName, err := extractRecordName(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("anexia: %w", err) + } + + recordID, err := d.findRecordID(ctx, dns01.UnFqdn(authZone), recordName, info.Value) + if err != nil { + return fmt.Errorf("anexia: %w", err) + } + + err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), recordID) + if err != nil { + return fmt.Errorf("anexia: delete TXT record: %w", err) + } + + return nil +} + +// findRecordID attempts to find the record ID from the zone response. +// If the record is not immediately available in the response, it retries by querying the zone. +func (d *DNSProvider) findRecordID(ctx context.Context, zoneName, recordName, rdata string) (string, error) { + return backoff.Retry(ctx, + func() (string, error) { + currentZone, err := d.client.GetZone(ctx, zoneName) + if err != nil { + return "", backoff.Permanent(fmt.Errorf("get zone: %w", err)) + } + + recordID := findRecordIdentifier(currentZone, recordName, rdata) + if recordID == "" { + return "", fmt.Errorf("get record identifier: %w", err) + } + + return recordID, nil + }, + backoff.WithBackOff(backoff.NewConstantBackOff(5*time.Second)), + backoff.WithMaxElapsedTime(300*time.Second), + ) +} + +func findRecordIdentifier(zone *internal.Zone, recordName, rdata string) string { + if len(zone.Revisions) == 0 { + return "" + } + + // Check the first revision (index 0) which should be the current one + + for _, record := range zone.Revisions[0].Records { + if record.Name != recordName || record.Type != "TXT" { + continue + } + + if record.RData == rdata || record.RData == strconv.Quote(rdata) { + return record.Identifier + } + } + + return "" +} + +func extractRecordName(fqdn, authZone string) (string, error) { + if dns01.UnFqdn(fqdn) == dns01.UnFqdn(authZone) { + // "@" for the root domain instead of an empty string. + return "@", nil + } + + return dns01.ExtractSubDomain(fqdn, authZone) +} diff --git a/providers/dns/anexia/anexia.toml b/providers/dns/anexia/anexia.toml new file mode 100644 index 000000000..332f0b8b1 --- /dev/null +++ b/providers/dns/anexia/anexia.toml @@ -0,0 +1,31 @@ +Name = "Anexia CloudDNS" +Description = '''''' +URL = "https://www.anexia-it.com/" +Code = "anexia" +Since = "v4.28.0" + +Example = ''' +ANEXIA_TOKEN=xxx \ +lego --dns anexia -d '*.example.com' -d example.com run +''' + +Additional = ''' +## Description + +You need to create an API token in the [Anexia Engine](https://engine.anexia-it.com/). + +The token must have permissions to manage DNS zones and records. +''' + +[Configuration] + [Configuration.Credentials] + ANEXIA_TOKEN = "API token for Anexia Engine" + [Configuration.Additional] + ANEXIA_API_URL = "API endpoint URL (default: https://engine.anexia-it.com)" + ANEXIA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + ANEXIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" + ANEXIA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + ANEXIA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://engine.anexia-it.com/docs/en/module/clouddns/api" diff --git a/providers/dns/anexia/anexia_test.go b/providers/dns/anexia/anexia_test.go new file mode 100644 index 000000000..9960c14d1 --- /dev/null +++ b/providers/dns/anexia/anexia_test.go @@ -0,0 +1,168 @@ +package anexia + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvToken, + EnvAPIURL). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success with token", + envVars: map[string]string{ + EnvToken: "secret", + }, + }, + { + desc: "missing token", + envVars: map[string]string{ + EnvToken: "", + }, + expected: "anexia: some credentials information are missing: ANEXIA_TOKEN", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + assert.NotNil(t, p.config) + assert.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + token string + expected string + }{ + { + desc: "success with token", + token: "secret", + }, + { + desc: "missing token", + token: "", + expected: "anexia: incomplete credentials, missing token", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Token = test.token + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + assert.NotNil(t, p.config) + assert.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + time.Sleep(2 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Token = "secret" + config.APIURL = server.URL + config.HTTPClient = server.Client() + + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader(). + WithAuthorization("Token secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("POST /api/clouddns/v1/zone.json/example.com/records", + servermock.ResponseFromInternal("create_record.json"), + servermock.CheckHeader(). + WithContentType("application/json; charset=utf-8"), + servermock.CheckRequestJSONBodyFromInternal("create_record-request.json")). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("GET /api/clouddns/v1/zone.json/example.com", + servermock.ResponseFromInternal("get_zone.json")). + Route("DELETE /api/clouddns/v1/zone.json/example.com/records/12345678-1234-1234-1234-123456789abc", + servermock.Noop()). + Build(t) + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/anexia/internal/client.go b/providers/dns/anexia/internal/client.go new file mode 100644 index 000000000..1a4159be0 --- /dev/null +++ b/providers/dns/anexia/internal/client.go @@ -0,0 +1,158 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://engine.anexia-it.com" + +// Client the Anexia CloudDNS API client. +type Client struct { + token string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(token string) (*Client, error) { + if token == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + token: token, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) CreateRecord(ctx context.Context, zoneName string, record Record) (*Zone, error) { + endpoint := c.BaseURL.JoinPath("api", "clouddns", "v1", "zone.json", zoneName, "records") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return nil, err + } + + var zone Zone + + err = c.do(req, &zone) + if err != nil { + return nil, err + } + + return &zone, nil +} + +func (c *Client) DeleteRecord(ctx context.Context, zoneName, recordID string) error { + endpoint := c.BaseURL.JoinPath("api", "clouddns", "v1", "zone.json", zoneName, "records", recordID) + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) GetZone(ctx context.Context, zoneName string) (*Zone, error) { + endpoint := c.BaseURL.JoinPath("api", "clouddns", "v1", "zone.json", zoneName) + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var zone Zone + + err = c.do(req, &zone) + if err != nil { + return nil, err + } + + return &zone, nil +} + +func (c *Client) do(req *http.Request, result any) error { + useragent.SetHeader(req.Header) + + req.Header.Add("Authorization", fmt.Sprintf("Token %s", c.token)) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json; charset=utf-8") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/anexia/internal/client_test.go b/providers/dns/anexia/internal/client_test.go new file mode 100644 index 000000000..be33d6f88 --- /dev/null +++ b/providers/dns/anexia/internal/client_test.go @@ -0,0 +1,133 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithAuthorization("Token secret"), + ) +} + +func TestClient_CreateRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /api/clouddns/v1/zone.json/example.com/records", + servermock.ResponseFromFixture("create_record.json"), + servermock.CheckHeader(). + WithContentType("application/json; charset=utf-8"), + servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). + Build(t) + + record := Record{ + Name: "_acme-challenge", + RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 300, + Type: "TXT", + } + + zone, err := client.CreateRecord(t.Context(), "example.com", record) + require.NoError(t, err) + + expected := &Zone{ + Name: "example.com", + TTL: 86400, + ZoneName: "example.com", + Revisions: []Revision{{ + Identifier: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Records: []Record{{ + Identifier: "12345678-1234-1234-1234-123456789abc", + Name: "_acme-challenge", + RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 300, + Type: "TXT", + }}, + State: "deployed", + }}, + } + + assert.Equal(t, expected, zone) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /api/clouddns/v1/zone.json/example.com/records/12345678-1234-1234-1234-123456789abc", + servermock.Noop()). + Build(t) + + err := client.DeleteRecord(t.Context(), "example.com", "12345678-1234-1234-1234-123456789abc") + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /api/clouddns/v1/zone.json/example.com/records/12345678-1234-1234-1234-123456789abc", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + err := client.DeleteRecord(t.Context(), "example.com", "12345678-1234-1234-1234-123456789abc") + require.EqualError(t, err, "401: Unauthorized") +} + +func TestClient_GetZone(t *testing.T) { + client := mockBuilder(). + Route("GET /api/clouddns/v1/zone.json/example.com", + servermock.ResponseFromFixture("get_zone.json")). + Build(t) + + zone, err := client.GetZone(t.Context(), "example.com") + require.NoError(t, err) + + expected := &Zone{ + Identifier: "fdb355ffd07c48aba3d4f6bf6a116296", + Name: "example.com", + TTL: 3600, + ZoneName: "", + Revisions: []Revision{{ + Identifier: "eeed7e08-f1ad-442b-9e75-369a0958c7d8", + Records: []Record{ + { + Identifier: "5ced498b-c89d-4487-824d-c03ded84f849", + Immutable: true, + Name: "@", + RData: "acns02.xaas.systems.", + Region: "9a1609af9dae4ce1a4ef63f51d305321", + TTL: 3600, + Type: "NS", + }, + { + Identifier: "12345678-1234-1234-1234-123456789abc", + Immutable: false, + Name: "_acme-challenge", + RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + Region: "", + TTL: 300, + Type: "TXT", + }, + }, + State: "active", + }}, + } + + assert.Equal(t, expected, zone) +} diff --git a/providers/dns/anexia/internal/fixtures/create_record-request.json b/providers/dns/anexia/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..e82add260 --- /dev/null +++ b/providers/dns/anexia/internal/fixtures/create_record-request.json @@ -0,0 +1,7 @@ +{ + "name": "_acme-challenge", + "type": "TXT", + "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "region": "", + "ttl": 300 +} diff --git a/providers/dns/anexia/internal/fixtures/create_record.json b/providers/dns/anexia/internal/fixtures/create_record.json new file mode 100644 index 000000000..8c4f2c149 --- /dev/null +++ b/providers/dns/anexia/internal/fixtures/create_record.json @@ -0,0 +1,38 @@ +{ + "name": "example.com", + "zone_name": "example.com", + "master": true, + "dnssec_mode": "managed", + "admin_email": "admin@example.com", + "refresh": 10800, + "retry": 3600, + "expire": 604800, + "ttl": 86400, + "customer": "ANX12345", + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z", + "published_at": "0001-01-01T00:00:00Z", + "is_editable": true, + "validation_level": 0, + "deployment_level": 0, + "revisions": [ + { + "created_at": "0001-01-01T00:00:00Z", + "identifier": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "modified_at": "0001-01-01T00:00:00Z", + "records": [ + { + "identifier": "12345678-1234-1234-1234-123456789abc", + "immutable": false, + "name": "_acme-challenge", + "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "region": "", + "ttl": 300, + "type": "TXT" + } + ], + "serial": 1, + "state": "deployed" + } + ] +} diff --git a/providers/dns/anexia/internal/fixtures/create_record_incomplete.json b/providers/dns/anexia/internal/fixtures/create_record_incomplete.json new file mode 100644 index 000000000..0515fcde3 --- /dev/null +++ b/providers/dns/anexia/internal/fixtures/create_record_incomplete.json @@ -0,0 +1,37 @@ +{ + "name": "example.com", + "zone_name": "example.com", + "master": true, + "dnssec_mode": "managed", + "admin_email": "admin@example.com", + "refresh": 10800, + "retry": 3600, + "expire": 604800, + "ttl": 86400, + "customer": "ANX12345", + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z", + "published_at": "0001-01-01T00:00:00Z", + "is_editable": true, + "validation_level": 0, + "deployment_level": 0, + "revisions": [ + { + "created_at": "0001-01-01T00:00:00Z", + "identifier": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "modified_at": "0001-01-01T00:00:00Z", + "records": [ + { + "immutable": false, + "name": "_acme-challenge", + "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "region": "", + "ttl": 300, + "type": "TXT" + } + ], + "serial": 1, + "state": "deployed" + } + ] +} diff --git a/providers/dns/anexia/internal/fixtures/error.json b/providers/dns/anexia/internal/fixtures/error.json new file mode 100644 index 000000000..afed571fa --- /dev/null +++ b/providers/dns/anexia/internal/fixtures/error.json @@ -0,0 +1,6 @@ +{ + "error": { + "code": 401, + "message": "Unauthorized" + } +} diff --git a/providers/dns/anexia/internal/fixtures/get_zone.json b/providers/dns/anexia/internal/fixtures/get_zone.json new file mode 100644 index 000000000..6e54594ff --- /dev/null +++ b/providers/dns/anexia/internal/fixtures/get_zone.json @@ -0,0 +1,82 @@ +{ + "identifier": "fdb355ffd07c48aba3d4f6bf6a116296", + "admin_email": "admin@example.com", + "created_at": "2019-02-06T10:02:07.000Z", + "current_revision": "eeed7e08-f1ad-442b-9e75-369a0958c7d8", + "deployment_level": 100, + "dns_servers": [ + { + "server": "acns01.xaas.systems", + "alias": null + }, + { + "server": "acns04.xaas.systems", + "alias": null + }, + { + "server": "acns02.xaas.systems", + "alias": null + }, + { + "server": "acns03.xaas.systems", + "alias": null + }, + { + "server": "acns05.xaas.systems", + "alias": null + } + ], + "dnsCluster": null, + "dnssec_ksk": null, + "dnssec_mode": "unvalidated", + "dnssec_sig_expires_at": null, + "dnssec_zsk": null, + "expire": 604800, + "inherit_ns_from": null, + "nameserver_set": null, + "master": true, + "master_ns": "acns02.xaas.systems.", + "name": "example.com", + "notify_allowed_ips": [ + "127.0.0.1" + ], + "published_at": "2023-06-20T08:41:06.000Z", + "refresh": 14400, + "revisions": [ + { + "created_at": "2023-06-20T08:41:06.000000Z", + "identifier": "eeed7e08-f1ad-442b-9e75-369a0958c7d8", + "modified_at": "2023-06-20T08:41:06.000000Z", + "records": [ + { + "identifier": "5ced498b-c89d-4487-824d-c03ded84f849", + "immutable": true, + "name": "@", + "rdata": "acns02.xaas.systems.", + "region": "9a1609af9dae4ce1a4ef63f51d305321", + "ttl": 3600, + "type": "NS", + "options": null + }, + { + "identifier": "12345678-1234-1234-1234-123456789abc", + "immutable": false, + "name": "_acme-challenge", + "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "region": "", + "ttl": 300, + "Type": "TXT" + } + ], + "serial": 14, + "state": "active" + } + ], + "retry": 3600, + "ttl": 3600, + "updated_at": "2020-06-04T18:34:22.000Z", + "validation_level": 100, + "whitelabel_config": null, + "is_editable": true, + "deploy_zone": "49459f420f614eb2a979fc7e961f83e6" +} diff --git a/providers/dns/anexia/internal/types.go b/providers/dns/anexia/internal/types.go new file mode 100644 index 000000000..f5546ca98 --- /dev/null +++ b/providers/dns/anexia/internal/types.go @@ -0,0 +1,38 @@ +package internal + +import "fmt" + +type APIError struct { + Details struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +func (a *APIError) Error() string { + return fmt.Sprintf("%d: %s", a.Details.Code, a.Details.Message) +} + +type Zone struct { + Identifier string `json:"identifier,omitempty"` + Name string `json:"name,omitempty"` + TTL int `json:"ttl,omitempty"` + ZoneName string `json:"zone_name,omitempty"` + Revisions []Revision `json:"revisions,omitempty"` +} + +type Revision struct { + Identifier string `json:"identifier,omitempty"` + Records []Record `json:"records,omitempty"` + State string `json:"state,omitempty"` +} + +type Record struct { + Identifier string `json:"identifier,omitempty"` + Immutable bool `json:"immutable,omitempty"` + Name string `json:"name,omitempty"` + RData string `json:"rdata,omitempty"` + Region string `json:"region"` + TTL int `json:"ttl,omitempty"` + Type string `json:"type,omitempty"` +} diff --git a/providers/dns/artfiles/artfiles.go b/providers/dns/artfiles/artfiles.go new file mode 100644 index 000000000..c918d77f6 --- /dev/null +++ b/providers/dns/artfiles/artfiles.go @@ -0,0 +1,204 @@ +// Package artfiles implements a DNS provider for solving the DNS-01 challenge using ArtFiles. +package artfiles + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/artfiles/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "ARTFILES_" + + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "PASSWORD" + + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Username string + Password string + + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 6*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for ArtFiles. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvPassword) + if err != nil { + return nil, fmt.Errorf("artfiles: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for ArtFiles. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("artfiles: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.Username, config.Password) + if err != nil { + return nil, fmt.Errorf("artfiles: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + zone, err := d.findZone(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("artfiles: %w", err) + } + + records, err := d.client.GetRecords(ctx, zone) + if err != nil { + return fmt.Errorf("artfiles: get records: %w", err) + } + + rv := internal.RecordValue{} + + if len(records["TXT"]) > 0 { + var raw string + + err = json.Unmarshal(records["TXT"], &raw) + if err != nil { + return fmt.Errorf("artfiles: unmarshal TXT records: %w", err) + } + + rv = internal.ParseRecordValue(raw) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) + if err != nil { + return fmt.Errorf("artfiles: %w", err) + } + + rv.Add(subDomain, info.Value) + + err = d.client.SetRecords(ctx, zone, "TXT", rv) + if err != nil { + return fmt.Errorf("artfiles: set TXT records: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + zone, err := d.findZone(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("artfiles: %w", err) + } + + records, err := d.client.GetRecords(ctx, zone) + if err != nil { + return fmt.Errorf("artfiles: get records: %w", err) + } + + var raw string + + err = json.Unmarshal(records["TXT"], &raw) + if err != nil { + return fmt.Errorf("artfiles: unmarshal TXT records: %w", err) + } + + rv := internal.ParseRecordValue(raw) + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) + if err != nil { + return fmt.Errorf("artfiles: %w", err) + } + + rv.RemoveValue(subDomain, info.Value) + + err = d.client.SetRecords(ctx, zone, "TXT", rv) + if err != nil { + return fmt.Errorf("artfiles: set TXT records: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (string, error) { + domains, err := d.client.GetDomains(ctx) + if err != nil { + return "", fmt.Errorf("artfiles: get domains: %w", err) + } + + var zone string + + for s := range dns01.UnFqdnDomainsSeq(fqdn) { + if slices.Contains(domains, s) { + zone = s + } + } + + if zone == "" { + return "", fmt.Errorf("artfiles: could not find the zone for domain %q", fqdn) + } + + return zone, nil +} diff --git a/providers/dns/artfiles/artfiles.toml b/providers/dns/artfiles/artfiles.toml new file mode 100644 index 000000000..00ff12342 --- /dev/null +++ b/providers/dns/artfiles/artfiles.toml @@ -0,0 +1,24 @@ +Name = "ArtFiles" +Description = '''''' +URL = "https://www.artfiles.de/extras/domains/" +Code = "artfiles" +Since = "v4.32.0" + +Example = ''' +ARTFILES_USERNAME="xxx" \ +ARTFILES_PASSWORD="yyy" \ +lego --dns artfiles -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + ARTFILES_USERNAME = "API username" + ARTFILES_PASSWORD = "API password" + [Configuration.Additional] + ARTFILES_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + ARTFILES_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 360)" + ARTFILES_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + ARTFILES_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://support.artfiles.de/DCP-API#dns" diff --git a/providers/dns/artfiles/artfiles_test.go b/providers/dns/artfiles/artfiles_test.go new file mode 100644 index 000000000..42490f10d --- /dev/null +++ b/providers/dns/artfiles/artfiles_test.go @@ -0,0 +1,228 @@ +package artfiles + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "secret", + }, + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvUsername: "", + EnvPassword: "secret", + }, + expected: "artfiles: some credentials information are missing: ARTFILES_USERNAME", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "", + }, + expected: "artfiles: some credentials information are missing: ARTFILES_PASSWORD", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "artfiles: some credentials information are missing: ARTFILES_USERNAME,ARTFILES_PASSWORD", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + username string + password string + expected string + }{ + { + desc: "success", + username: "user", + password: "secret", + }, + { + desc: "missing username", + password: "secret", + expected: "artfiles: credentials missing", + }, + { + desc: "missing Example", + username: "user", + expected: "artfiles: credentials missing", + }, + { + desc: "missing credentials", + expected: "artfiles: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Username = test.username + config.Password = test.password + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Username = "user" + config.Password = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithBasicAuth("user", "secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("GET /domain/get_domains.html", + servermock.ResponseFromInternal("domains.txt"), + ). + Route("GET /dns/get_dns.html", + servermock.ResponseFromInternal("get_dns.json"), + servermock.CheckQueryParameter().Strict(). + With("domain", "example.com"), + ). + Route("POST /dns/set_dns.html", + servermock.ResponseFromInternal("set_dns.json"), + servermock.CheckQueryParameter().Strict(). + With("TXT", `@ "v=spf1 a mx ~all" +_acme-challenge "TheAcmeChallenge" +_acme-challenge "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" +_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf" +_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;" +_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com" +selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff" +selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff"`). + With("domain", "example.com"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("GET /domain/get_domains.html", + servermock.ResponseFromInternal("domains.txt"), + ). + Route("GET /dns/get_dns.html", + servermock.ResponseFromInternal("get_dns.json"), + servermock.CheckQueryParameter().Strict(). + With("domain", "example.com"), + ). + Route("POST /dns/set_dns.html", + servermock.ResponseFromInternal("set_dns.json"), + servermock.CheckQueryParameter().Strict(). + With("TXT", `@ "v=spf1 a mx ~all" +_acme-challenge "TheAcmeChallenge" +_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf" +_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;" +_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com" +selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff" +selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff"`). + With("domain", "example.com"), + ). + Build(t) + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/artfiles/internal/client.go b/providers/dns/artfiles/internal/client.go new file mode 100644 index 000000000..61b350511 --- /dev/null +++ b/providers/dns/artfiles/internal/client.go @@ -0,0 +1,133 @@ +package internal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://dcp.c.artfiles.de/api/" + +// Client the ArtFiles API client. +type Client struct { + username string + password string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(username, password string) (*Client, error) { + if username == "" || password == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + username: username, + password: password, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) GetDomains(ctx context.Context) ([]string, error) { + endpoint := c.BaseURL.JoinPath("domain", "get_domains.html") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + raw, err := c.do(req) + if err != nil { + return nil, err + } + + return parseDomains(string(raw)) +} + +func (c *Client) GetRecords(ctx context.Context, domain string) (map[string]json.RawMessage, error) { + endpoint := c.BaseURL.JoinPath("dns", "get_dns.html") + + query := endpoint.Query() + query.Set("domain", domain) + + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + raw, err := c.do(req) + if err != nil { + return nil, err + } + + var result Records + + err = json.Unmarshal(raw, &result) + if err != nil { + return nil, errutils.NewUnmarshalError(req, http.StatusOK, raw, err) + } + + return result.Data, nil +} + +func (c *Client) SetRecords(ctx context.Context, domain, rType string, value RecordValue) error { + endpoint := c.BaseURL.JoinPath("dns", "set_dns.html") + + query := endpoint.Query() + query.Set("domain", domain) + query.Set(rType, value.String()) + + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), nil) + if err != nil { + return fmt.Errorf("unable to create request: %w", err) + } + + _, err = c.do(req) + + return err +} + +func (c *Client) do(req *http.Request) ([]byte, error) { + useragent.SetHeader(req.Header) + + req.SetBasicAuth(c.username, c.password) + + if req.Method == http.MethodPost { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + if resp.StatusCode/100 != 2 { + return nil, errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return raw, nil +} diff --git a/providers/dns/artfiles/internal/client_test.go b/providers/dns/artfiles/internal/client_test.go new file mode 100644 index 000000000..cc76f06f5 --- /dev/null +++ b/providers/dns/artfiles/internal/client_test.go @@ -0,0 +1,89 @@ +package internal + +import ( + "encoding/json" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("user", "secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithBasicAuth("user", "secret"), + ) +} + +func TestClient_GetDomains(t *testing.T) { + client := mockBuilder(). + Route("GET /domain/get_domains.html", + servermock.ResponseFromFixture("domains.txt"), + ). + Build(t) + + zones, err := client.GetDomains(t.Context()) + require.NoError(t, err) + + expected := []string{"example.com", "example.org", "example.net"} + + assert.Equal(t, expected, zones) +} + +func TestClient_GetRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/get_dns.html", + servermock.ResponseFromFixture("get_dns.json"), + servermock.CheckQueryParameter().Strict(). + With("domain", "example.com"), + ). + Build(t) + + records, err := client.GetRecords(t.Context(), "example.com") + require.NoError(t, err) + + expected := map[string]json.RawMessage{ + "A": json.RawMessage(strconv.Quote("sub1 1.2.3.4\nsub2 1.2.3.4\nsub3 1.2.3.4\nsub4 1.2.3.4\nsub5 1.2.3.4\nsub6 1.2.3.4\nsub7 1.2.3.4\nsub8 1.2.3.4\nsub9 1.2.3.4\nsub10 1.2.3.4\nsub11 1.2.3.4\nsub12 1.2.3.4\nsub13 1.2.3.4\nsub14 1.2.3.4\nsub15 1.2.3.4\nsub16 1.2.3.4\nsub17 1.2.3.4\nsub18 1.2.3.4\n@ 1.2.3.4")), + "AAAA": json.RawMessage(strconv.Quote("")), + "CAA": json.RawMessage(strconv.Quote("@ 128 iodef \"mailto:someone@example.tld\"\n@ 128 issue \"letsencrypt.org\"\n@ 128 issuewild \"letsencrypt.org\"")), + "CName": json.RawMessage(strconv.Quote("some cname.to.example.tld.")), + "MX": json.RawMessage(strconv.Quote("10 mail.example.tld.")), + "SRV": json.RawMessage(strconv.Quote("_imap._tcp 0 0 0 .\n_imaps._tcp 0 1 993 mail.example.tld.\n_pop3._tcp 0 0 0 .\n_pop3s._tcp 0 0 0 .")), + "TLSA": json.RawMessage(strconv.Quote("_25._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_25._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_25._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_465._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_465._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_465._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_587._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_587._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_587._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2")), + "TXT": json.RawMessage(strconv.Quote("_dmarc \"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\"\n_mta-sts \"v=STSv1;id=yyyymmddTHHMMSS;\"\n_smtp._tls \"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\"\n@ \"v=spf1 a mx ~all\"\nselector._domainkey \"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\"\nselectorecc._domainkey \"v=DKIM1;k=ed25519;p=Base64Stuff\"\n_acme-challenge \"TheAcmeChallenge\"")), + "TTL": json.RawMessage("3600"), + "comment": json.RawMessage(strconv.Quote("TLSA RR:\nInfo -> https://dnssec-stats.ant.isi.edu/~viktor/x3hosts.html\nTest 1 -> https://stats.dnssec-tools.org/explore/?example.tld\nTest 2 -> https://dane.sys4.de/smtp/example.tld\n\nSMIMEA RR:\nGenerator -> https://www.smimea.info/smimea-generator.php\nTest -> https://www.smimea.info/smimea-test.php")), + "nameserver": json.RawMessage(strconv.Quote("auth1.artfiles.de.\nauth2.artfiles.de.")), + } + + assert.Equal(t, expected, records) +} + +func TestClient_SetRecords(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/set_dns.html", + servermock.ResponseFromFixture("set_dns.json"), + servermock.CheckQueryParameter().Strict(). + With("TXT", "a b\nc \"d\""). + With("domain", "example.com"), + ). + Build(t) + + err := client.SetRecords(t.Context(), "example.com", "TXT", RecordValue{"c": []string{`"d"`}, "a": []string{"b"}}) + require.NoError(t, err) +} diff --git a/providers/dns/artfiles/internal/fixtures/domains.txt b/providers/dns/artfiles/internal/fixtures/domains.txt new file mode 100644 index 000000000..b8a1247d2 --- /dev/null +++ b/providers/dns/artfiles/internal/fixtures/domains.txt @@ -0,0 +1,3 @@ +example.com normal 2026-10-01 2017-09-18 163477 +example.org normal 2026-08-01 2016-07-07 156216 +example.net normal 2026-07-01 2017-06-06 162462 diff --git a/providers/dns/artfiles/internal/fixtures/get_dns.json b/providers/dns/artfiles/internal/fixtures/get_dns.json new file mode 100644 index 000000000..fa672e0e1 --- /dev/null +++ b/providers/dns/artfiles/internal/fixtures/get_dns.json @@ -0,0 +1,16 @@ +{ + "data": { + "SRV": "_imap._tcp 0 0 0 .\n_imaps._tcp 0 1 993 mail.example.tld.\n_pop3._tcp 0 0 0 .\n_pop3s._tcp 0 0 0 .", + "AAAA": "", + "MX": "10 mail.example.tld.", + "CAA": "@ 128 iodef \"mailto:someone@example.tld\"\n@ 128 issue \"letsencrypt.org\"\n@ 128 issuewild \"letsencrypt.org\"", + "TTL": 3600, + "comment": "TLSA RR:\nInfo -> https://dnssec-stats.ant.isi.edu/~viktor/x3hosts.html\nTest 1 -> https://stats.dnssec-tools.org/explore/?example.tld\nTest 2 -> https://dane.sys4.de/smtp/example.tld\n\nSMIMEA RR:\nGenerator -> https://www.smimea.info/smimea-generator.php\nTest -> https://www.smimea.info/smimea-test.php", + "TXT": "_dmarc \"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\"\n_mta-sts \"v=STSv1;id=yyyymmddTHHMMSS;\"\n_smtp._tls \"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\"\n@ \"v=spf1 a mx ~all\"\nselector._domainkey \"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\"\nselectorecc._domainkey \"v=DKIM1;k=ed25519;p=Base64Stuff\"\n_acme-challenge \"TheAcmeChallenge\"", + "A": "sub1 1.2.3.4\nsub2 1.2.3.4\nsub3 1.2.3.4\nsub4 1.2.3.4\nsub5 1.2.3.4\nsub6 1.2.3.4\nsub7 1.2.3.4\nsub8 1.2.3.4\nsub9 1.2.3.4\nsub10 1.2.3.4\nsub11 1.2.3.4\nsub12 1.2.3.4\nsub13 1.2.3.4\nsub14 1.2.3.4\nsub15 1.2.3.4\nsub16 1.2.3.4\nsub17 1.2.3.4\nsub18 1.2.3.4\n@ 1.2.3.4", + "nameserver": "auth1.artfiles.de.\nauth2.artfiles.de.", + "CName": "some cname.to.example.tld.", + "TLSA": "_25._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_25._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_25._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_465._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_465._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_465._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_587._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_587._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_587._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2" + }, + "status": "OK" +} diff --git a/providers/dns/artfiles/internal/fixtures/set_dns.json b/providers/dns/artfiles/internal/fixtures/set_dns.json new file mode 100644 index 000000000..7cacb33e5 --- /dev/null +++ b/providers/dns/artfiles/internal/fixtures/set_dns.json @@ -0,0 +1,4 @@ +{ + "status": "OK", + "error": "" +} diff --git a/providers/dns/artfiles/internal/fixtures/txt_record-multiple.txt b/providers/dns/artfiles/internal/fixtures/txt_record-multiple.txt new file mode 100644 index 000000000..461489c77 --- /dev/null +++ b/providers/dns/artfiles/internal/fixtures/txt_record-multiple.txt @@ -0,0 +1,8 @@ +_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf" +_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;" +_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com" +@ "v=spf1 a mx ~all" +selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff" +selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff" +_acme-challenge "xxx" +_acme-challenge "yyy" diff --git a/providers/dns/artfiles/internal/fixtures/txt_record.txt b/providers/dns/artfiles/internal/fixtures/txt_record.txt new file mode 100644 index 000000000..5a6259b14 --- /dev/null +++ b/providers/dns/artfiles/internal/fixtures/txt_record.txt @@ -0,0 +1,7 @@ +_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf" +_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;" +_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com" +@ "v=spf1 a mx ~all" +selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff" +selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff" +_acme-challenge "TheAcmeChallenge" diff --git a/providers/dns/artfiles/internal/types.go b/providers/dns/artfiles/internal/types.go new file mode 100644 index 000000000..c70ab34da --- /dev/null +++ b/providers/dns/artfiles/internal/types.go @@ -0,0 +1,109 @@ +package internal + +import ( + "encoding/csv" + "encoding/json" + "errors" + "io" + "maps" + "slices" + "strconv" + "strings" + "unicode" +) + +type Records struct { + Data map[string]json.RawMessage `json:"data"` + Status string `json:"status"` +} + +type RecordValue map[string][]string + +func (r RecordValue) Set(key, value string) { + r[key] = []string{strconv.Quote(value)} +} + +func (r RecordValue) Add(key, value string) { + r[key] = append(r[key], strconv.Quote(value)) +} + +func (r RecordValue) Delete(key string) { + delete(r, key) +} + +func (r RecordValue) RemoveValue(key, value string) { + if len(r[key]) == 0 { + return + } + + quotedValue := strconv.Quote(value) + + var data []string + + for _, s := range r[key] { + if s != quotedValue { + data = append(data, s) + } + } + + r[key] = data + + if len(r[key]) == 0 { + r.Delete(key) + } +} + +func (r RecordValue) String() string { + var parts []string + + for _, key := range slices.Sorted(maps.Keys(r)) { + for _, s := range r[key] { + parts = append(parts, key+" "+s) + } + } + + return strings.Join(parts, "\n") +} + +func ParseRecordValue(lines string) RecordValue { + data := make(RecordValue) + + for line := range strings.Lines(lines) { + line = strings.TrimSpace(line) + + idx := strings.IndexFunc(line, unicode.IsSpace) + + data[line[:idx]] = append(data[line[:idx]], line[idx+1:]) + } + + return data +} + +func parseDomains(input string) ([]string, error) { + reader := csv.NewReader(strings.NewReader(input)) + reader.Comma = '\t' + reader.TrimLeadingSpace = true + reader.LazyQuotes = true + + var data []string + + for { + record, err := reader.Read() + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + return nil, err + } + + if len(record) < 1 { + // Malformed line + continue + } + + data = append(data, record[0]) + } + + return data, nil +} diff --git a/providers/dns/artfiles/internal/types_test.go b/providers/dns/artfiles/internal/types_test.go new file mode 100644 index 000000000..3b219f39f --- /dev/null +++ b/providers/dns/artfiles/internal/types_test.go @@ -0,0 +1,183 @@ +package internal + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRecordValue_Set(t *testing.T) { + rv := make(RecordValue) + + rv.Set("a", "1") + rv.Set("b", "2") + rv.Set("b", "3") + + assert.Equal(t, "a \"1\"\nb \"3\"", rv.String()) +} + +func TestRecordValue_Add(t *testing.T) { + rv := make(RecordValue) + + rv.Add("a", "1") + rv.Add("b", "2") + rv.Add("b", "3") + + assert.Equal(t, "a \"1\"\nb \"2\"\nb \"3\"", rv.String()) +} + +func TestRecordValue_Delete(t *testing.T) { + rv := make(RecordValue) + + rv.Set("a", "1") + rv.Add("b", "2") + + rv.Delete("b") + + assert.Equal(t, "a \"1\"", rv.String()) +} + +func TestRecordValue_RemoveValue(t *testing.T) { + testCases := []struct { + desc string + data map[string][]string + toRemove map[string][]string + expected string + }{ + { + desc: "remove the only value", + data: map[string][]string{ + "a": {"1"}, + }, + toRemove: map[string][]string{ + "a": {"1"}, + }, + expected: ``, + }, + { + desc: "remove value in the middle", + data: map[string][]string{ + "a": {"1", "2", "3"}, + }, + toRemove: map[string][]string{ + "a": {"2"}, + }, + expected: "a \"1\"\na \"3\"", + }, + { + desc: "remove value at the beginning", + data: map[string][]string{ + "a": {"1", "2", "3"}, + }, + toRemove: map[string][]string{ + "a": {"1"}, + }, + expected: "a \"2\"\na \"3\"", + }, + { + desc: "remove value at the end", + data: map[string][]string{ + "a": {"1", "2", "3"}, + }, + toRemove: map[string][]string{ + "a": {"3"}, + }, + expected: "a \"1\"\na \"2\"", + }, + { + desc: "remove all (delete)", + data: map[string][]string{ + "a": {"1", "2", "3"}, + }, + toRemove: map[string][]string{ + "a": {"1", "2", "3"}, + }, + expected: ``, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + rv := make(RecordValue) + + for k, values := range test.data { + for _, v := range values { + rv.Add(k, v) + } + } + + for k, values := range test.toRemove { + for _, v := range values { + rv.RemoveValue(k, v) + } + } + + assert.Equal(t, test.expected, rv.String()) + }) + } +} + +func TestParseRecordValue(t *testing.T) { + testCases := []struct { + desc string + filename string + expected RecordValue + }{ + { + desc: "simple", + filename: "txt_record.txt", + expected: RecordValue{ + "@": []string{"\"v=spf1 a mx ~all\""}, + "_acme-challenge": []string{"\"TheAcmeChallenge\""}, + "_dmarc": []string{"\"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\""}, + "_mta-sts": []string{"\"v=STSv1;id=yyyymmddTHHMMSS;\""}, + "_smtp._tls": []string{"\"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\""}, + "selector._domainkey": []string{"\"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\""}, + "selectorecc._domainkey": []string{"\"v=DKIM1;k=ed25519;p=Base64Stuff\""}, + }, + }, + { + desc: "multiple values with the same key", + filename: "txt_record-multiple.txt", + expected: RecordValue{ + "@": []string{"\"v=spf1 a mx ~all\""}, + "_acme-challenge": []string{"\"xxx\"", "\"yyy\""}, + "_dmarc": []string{"\"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\""}, + "_mta-sts": []string{"\"v=STSv1;id=yyyymmddTHHMMSS;\""}, + "_smtp._tls": []string{"\"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\""}, + "selector._domainkey": []string{"\"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\""}, + "selectorecc._domainkey": []string{"\"v=DKIM1;k=ed25519;p=Base64Stuff\""}, + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + file, err := os.ReadFile(filepath.Join("fixtures", test.filename)) + require.NoError(t, err) + + data := ParseRecordValue(string(file)) + + assert.Equal(t, test.expected, data) + }) + } +} + +func Test_parseDomains(t *testing.T) { + file, err := os.ReadFile(filepath.FromSlash("./fixtures/domains.txt")) + require.NoError(t, err) + + domains, err := parseDomains(string(file)) + require.NoError(t, err) + + expected := []string{"example.com", "example.org", "example.net"} + + assert.Equal(t, expected, domains) +} diff --git a/providers/dns/arvancloud/arvancloud.go b/providers/dns/arvancloud/arvancloud.go index 759d1922a..ed1d5ff7a 100644 --- a/providers/dns/arvancloud/arvancloud.go +++ b/providers/dns/arvancloud/arvancloud.go @@ -9,13 +9,13 @@ import ( "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/arvancloud/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) -const minTTL = 600 - // Environment variables names. const ( envNamespace = "ARVANCLOUD_" @@ -28,6 +28,10 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const minTTL = 600 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -92,6 +96,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -161,6 +167,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("arvancloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } diff --git a/providers/dns/arvancloud/arvancloud.toml b/providers/dns/arvancloud/arvancloud.toml index 3c0fed4ac..aa5cafb51 100644 --- a/providers/dns/arvancloud/arvancloud.toml +++ b/providers/dns/arvancloud/arvancloud.toml @@ -6,17 +6,17 @@ Since = "v3.8.0" Example = ''' ARVANCLOUD_API_KEY="Apikey xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ -lego --email you@example.com --dns arvancloud -d '*.example.com' -d example.com run +lego --dns arvancloud -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ARVANCLOUD_API_KEY = "API key" [Configuration.Additional] - ARVANCLOUD_POLLING_INTERVAL = "Time between DNS propagation check" - ARVANCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - ARVANCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge" - ARVANCLOUD_HTTP_TIMEOUT = "API request timeout" + ARVANCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + ARVANCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + ARVANCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" + ARVANCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.arvancloud.ir/docs/api/cdn/4.0" diff --git a/providers/dns/arvancloud/arvancloud_test.go b/providers/dns/arvancloud/arvancloud_test.go index c31edf021..24013c437 100644 --- a/providers/dns/arvancloud/arvancloud_test.go +++ b/providers/dns/arvancloud/arvancloud_test.go @@ -37,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -104,6 +105,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -117,6 +119,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/arvancloud/internal/client.go b/providers/dns/arvancloud/internal/client.go index 3caff392a..b447d97c4 100644 --- a/providers/dns/arvancloud/internal/client.go +++ b/providers/dns/arvancloud/internal/client.go @@ -70,6 +70,7 @@ func (c *Client) getRecords(ctx context.Context, domain, search string) ([]DNSRe } response := &apiResponse[[]DNSRecord]{} + err = c.do(req, http.StatusOK, response) if err != nil { return nil, fmt.Errorf("could not get records %s: Domain: %s: %w", search, domain, err) @@ -89,6 +90,7 @@ func (c *Client) CreateRecord(ctx context.Context, domain string, record DNSReco } response := &apiResponse[*DNSRecord]{} + err = c.do(req, http.StatusCreated, response) if err != nil { return nil, fmt.Errorf("could not create record; Domain: %s: %w", domain, err) diff --git a/providers/dns/arvancloud/internal/client_test.go b/providers/dns/arvancloud/internal/client_test.go index 5c9154c62..183a8acfd 100644 --- a/providers/dns/arvancloud/internal/client_test.go +++ b/providers/dns/arvancloud/internal/client_test.go @@ -1,103 +1,55 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, apiKey string) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder(apiKey string) *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(apiKey) + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(apiKey) - client.baseURL, _ = url.Parse(server.URL) - client.HTTPClient = server.Client() - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization(apiKey)) } func TestClient_GetTxtRecord(t *testing.T) { const apiKey = "myKeyA" - client, mux := setupTest(t, apiKey) - const domain = "example.com" - mux.HandleFunc("/cdn/4.0/domains/"+domain+"/dns-records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } + client := mockBuilder(apiKey). + Route("GET /cdn/4.0/domains/"+domain+"/dns-records", + servermock.ResponseFromFixture("get_txt_record.json"), + servermock.CheckQueryParameter().With("search", "acme-challenge")). + Build(t) - auth := req.Header.Get(authorizationHeader) - if auth != apiKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open("./fixtures/get_txt_record.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - _, err := client.GetTxtRecord(context.Background(), domain, "_acme-challenge", "txtxtxt") + _, err := client.GetTxtRecord(t.Context(), domain, "_acme-challenge", "txtxtxt") require.NoError(t, err) } func TestClient_CreateRecord(t *testing.T) { const apiKey = "myKeyB" - client, mux := setupTest(t, apiKey) - const domain = "example.com" - mux.HandleFunc("/cdn/4.0/domains/"+domain+"/dns-records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != apiKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open("./fixtures/create_txt_record.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - rw.WriteHeader(http.StatusCreated) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(apiKey). + Route("POST /cdn/4.0/domains/"+domain+"/dns-records", + servermock.ResponseFromFixture("create_txt_record.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). + Build(t) record := DNSRecord{ Name: "_acme-challenge", @@ -106,13 +58,13 @@ func TestClient_CreateRecord(t *testing.T) { TTL: 600, } - newRecord, err := client.CreateRecord(context.Background(), domain, record) + newRecord, err := client.CreateRecord(t.Context(), domain, record) require.NoError(t, err) expected := &DNSRecord{ ID: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", Type: "txt", - Value: map[string]interface{}{"text": "txtxtxt"}, + Value: map[string]any{"text": "txtxtxt"}, Name: "_acme-challenge", TTL: 120, UpstreamHTTPS: "default", @@ -129,24 +81,15 @@ func TestClient_CreateRecord(t *testing.T) { func TestClient_DeleteRecord(t *testing.T) { const apiKey = "myKeyC" - client, mux := setupTest(t, apiKey) + const ( + domain = "example.com" + recordID = "recordId" + ) - const domain = "example.com" - const recordID = "recordId" + client := mockBuilder(apiKey). + Route("DELETE /cdn/4.0/domains/"+domain+"/dns-records/"+recordID, nil). + Build(t) - mux.HandleFunc("/cdn/4.0/domains/"+domain+"/dns-records/"+recordID, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != apiKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - }) - - err := client.DeleteRecord(context.Background(), domain, recordID) + err := client.DeleteRecord(t.Context(), domain, recordID) require.NoError(t, err) } diff --git a/providers/dns/arvancloud/internal/fixtures/create_record-request.json b/providers/dns/arvancloud/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..48a7124f6 --- /dev/null +++ b/providers/dns/arvancloud/internal/fixtures/create_record-request.json @@ -0,0 +1,8 @@ +{ + "type": "txt", + "value": { + "text": "txtxtxt" + }, + "name": "_acme-challenge", + "ttl": 600 +} diff --git a/providers/dns/auroradns/auroradns.go b/providers/dns/auroradns/auroradns.go index 2f759d4a3..50d2fbc25 100644 --- a/providers/dns/auroradns/auroradns.go +++ b/providers/dns/auroradns/auroradns.go @@ -7,13 +7,14 @@ import ( "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/miekg/dns" "github.com/nrdcg/auroradns" ) -const defaultBaseURL = "https://api.auroradns.eu" - // Environment variables names. const ( envNamespace = "AURORA_" @@ -27,6 +28,10 @@ const ( EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +const defaultBaseURL = "https://api.auroradns.eu" + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string @@ -48,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. @@ -90,7 +96,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, fmt.Errorf("aurora: %w", err) } - client, err := auroradns.NewClient(tr.Client(), auroradns.WithBaseURL(config.BaseURL)) + client, err := auroradns.NewClient(clientdebug.Wrap(tr.Client()), auroradns.WithBaseURL(config.BaseURL)) if err != nil { return nil, fmt.Errorf("aurora: %w", err) } @@ -158,7 +164,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("aurora: unknown recordID for %q", info.EffectiveFQDN) } - authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN)) + authZone, err := dns01.FindZoneByFqdn(dns.Fqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("aurora: could not find zone for domain %q: %w", domain, err) } diff --git a/providers/dns/auroradns/auroradns.toml b/providers/dns/auroradns/auroradns.toml index 4ee8c0975..59b5e7ab1 100644 --- a/providers/dns/auroradns/auroradns.toml +++ b/providers/dns/auroradns/auroradns.toml @@ -7,7 +7,7 @@ Since = "v0.4.0" Example = ''' AURORA_API_KEY=xxxxx \ AURORA_SECRET=yyyyyy \ -lego --email you@example.com --dns auroradns -d '*.example.com' -d example.com run +lego --dns auroradns -d '*.example.com' -d example.com run ''' [Configuration] @@ -16,9 +16,9 @@ lego --email you@example.com --dns auroradns -d '*.example.com' -d example.com r AURORA_SECRET = "Secret password to be used" [Configuration.Additional] AURORA_ENDPOINT = "API endpoint URL" - AURORA_POLLING_INTERVAL = "Time between DNS propagation check" - AURORA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - AURORA_TTL = "The TTL of the TXT record used for the DNS challenge" + AURORA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + AURORA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + AURORA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" [Links] API = "https://libcloud.readthedocs.io/en/latest/dns/drivers/auroradns.html#api-docs" diff --git a/providers/dns/auroradns/auroradns_test.go b/providers/dns/auroradns/auroradns_test.go index cbd51b830..8a9835d9c 100644 --- a/providers/dns/auroradns/auroradns_test.go +++ b/providers/dns/auroradns/auroradns_test.go @@ -1,35 +1,32 @@ package auroradns import ( - "fmt" - "io" "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester" - "github.com/stretchr/testify/assert" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/nrdcg/auroradns" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvAPIKey, EnvSecret) -func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIKey = "asdf1234" + config.Secret = "key" + config.BaseURL = server.URL - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - config := NewDefaultConfig() - config.APIKey = "asdf1234" - config.Secret = "key" - config.BaseURL = server.URL - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - return provider, mux + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader(). + WithContentType("application/json"). + WithRegexp("Authorization", `AuroraDNSv1 .+`). + WithRegexp("X-Auroradns-Date", `[0-9TZ]+`)) } func TestNewDNSProvider(t *testing.T) { @@ -74,6 +71,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -145,76 +143,51 @@ func TestNewDNSProviderConfig(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/zones", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - - w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, `[{ - "id": "c56a4180-65aa-42ec-a945-5fd21dec0538", - "name": "example.com" - }]`) - }) - - mux.HandleFunc("/zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type") - - reqBody, err := io.ReadAll(r.Body) - require.NoError(t, err) - assert.JSONEq(t, `{"type":"TXT","name":"_acme-challenge","content":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":300}`, string(reqBody)) - - w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, `{ - "id": "c56a4180-65aa-42ec-a945-5fd21dec0538", - "type": "TXT", - "name": "_acme-challenge", - "ttl": 300 - }`) - }) + provider := mockBuilder(). + Route("GET /zones", + servermock.JSONEncode([]auroradns.Zone{{ + ID: "c56a4180-65aa-42ec-a945-5fd21dec0538", + Name: "example.com", + }}). + WithStatusCode(http.StatusCreated)). + Route("POST /zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records", + servermock.JSONEncode(auroradns.Record{ + ID: "ec56a4180-65aa-42ec-a945-5fd21dec0538", + RecordType: "TXT", + Name: "_acme-challenge", + TTL: 300, + }). + WithStatusCode(http.StatusCreated)). + Build(t) err := provider.Present("example.com", "", "foobar") - require.NoError(t, err, "fail to create TXT record") + require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/zones", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method) - - w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, `[{ - "id": "c56a4180-65aa-42ec-a945-5fd21dec0538", - "name": "example.com" - }]`) - }) - - mux.HandleFunc("/zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - - w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, `{ - "id": "ec56a4180-65aa-42ec-a945-5fd21dec0538", - "type": "TXT", - "name": "_acme-challenge", - "ttl": 300 - }`) - }) - - mux.HandleFunc("/zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records/ec56a4180-65aa-42ec-a945-5fd21dec0538", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodDelete, r.Method) - - assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type") - - w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, `{}`) - }) + provider := mockBuilder(). + Route("GET /zones", + servermock.JSONEncode([]auroradns.Zone{{ + ID: "c56a4180-65aa-42ec-a945-5fd21dec0538", + Name: "example.com", + }}). + WithStatusCode(http.StatusCreated)). + Route("POST /zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records", + servermock.JSONEncode(auroradns.Record{ + ID: "ec56a4180-65aa-42ec-a945-5fd21dec0538", + RecordType: "TXT", + Name: "_acme-challenge", + TTL: 300, + }). + WithStatusCode(http.StatusCreated)). + Route("DELETE /zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records/ec56a4180-65aa-42ec-a945-5fd21dec0538", + servermock.RawStringResponse("{}"). + WithStatusCode(http.StatusCreated)). + Build(t) err := provider.Present("example.com", "", "foobar") - require.NoError(t, err, "fail to create TXT record") + require.NoError(t, err) err = provider.CleanUp("example.com", "", "foobar") - require.NoError(t, err, "fail to remove TXT record") + require.NoError(t, err) } diff --git a/providers/dns/autodns/autodns.go b/providers/dns/autodns/autodns.go index 67b98d177..8a9361bc0 100644 --- a/providers/dns/autodns/autodns.go +++ b/providers/dns/autodns/autodns.go @@ -9,9 +9,11 @@ import ( "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/autodns/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -29,6 +31,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Endpoint *url.URL @@ -102,6 +106,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } @@ -122,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 @@ -141,8 +147,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { Value: info.Value, }} - if err := d.client.RemoveTXTRecords(context.Background(), info.EffectiveFQDN, records); err != nil { - return fmt.Errorf("autodns: %w", err) + _, err := d.client.RemoveRecords(context.Background(), info.EffectiveFQDN, records) + if err != nil { + return fmt.Errorf("autodns: remove record: %w", err) } return nil diff --git a/providers/dns/autodns/autodns.toml b/providers/dns/autodns/autodns.toml index 353f223a9..2798d4cee 100644 --- a/providers/dns/autodns/autodns.toml +++ b/providers/dns/autodns/autodns.toml @@ -7,7 +7,7 @@ Since = "v3.2.0" Example = ''' AUTODNS_API_USER=username \ AUTODNS_API_PASSWORD=supersecretpassword \ -lego --email you@example.com --dns autodns -d '*.example.com' -d example.com run +lego --dns autodns -d '*.example.com' -d example.com run ''' [Configuration] @@ -17,10 +17,10 @@ lego --email you@example.com --dns autodns -d '*.example.com' -d example.com run [Configuration.Additional] AUTODNS_ENDPOINT = "API endpoint URL, defaults to https://api.autodns.com/v1/" AUTODNS_CONTEXT = "API context (4 for production, 1 for testing. Defaults to 4)" - AUTODNS_TTL = "The TTL of the TXT record used for the DNS challenge" - AUTODNS_POLLING_INTERVAL = "Time between DNS propagation check" - AUTODNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - AUTODNS_HTTP_TIMEOUT = "API request timeout, defaults to 30 seconds" + AUTODNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" + AUTODNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + AUTODNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + AUTODNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://help.internetx.com/display/APIJSONEN" diff --git a/providers/dns/autodns/autodns_test.go b/providers/dns/autodns/autodns_test.go index bc9f3067e..632d24705 100644 --- a/providers/dns/autodns/autodns_test.go +++ b/providers/dns/autodns/autodns_test.go @@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -131,6 +132,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -144,6 +146,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/autodns/internal/client.go b/providers/dns/autodns/internal/client.go index 363250d0a..d92490a60 100644 --- a/providers/dns/autodns/internal/client.go +++ b/providers/dns/autodns/internal/client.go @@ -31,7 +31,7 @@ type Client struct { } // NewClient creates a new Client. -func NewClient(username string, password string, clientContext int) *Client { +func NewClient(username, password string, clientContext int) *Client { baseURL, _ := url.Parse(DefaultEndpoint) return &Client{ @@ -43,23 +43,22 @@ func NewClient(username string, password string, clientContext int) *Client { } } -// AddTxtRecords adds TXT records. -func (c *Client) AddTxtRecords(ctx context.Context, domain string, records []*ResourceRecord) (*Zone, error) { +// AddRecords adds records. +func (c *Client) AddRecords(ctx context.Context, domain string, records []*ResourceRecord) (*DataZoneResponse, error) { zoneStream := &ZoneStream{Adds: records} return c.updateZone(ctx, domain, zoneStream) } -// RemoveTXTRecords removes TXT records. -func (c *Client) RemoveTXTRecords(ctx context.Context, domain string, records []*ResourceRecord) error { +// RemoveRecords removes records. +func (c *Client) RemoveRecords(ctx context.Context, domain string, records []*ResourceRecord) (*DataZoneResponse, error) { zoneStream := &ZoneStream{Removes: records} - _, err := c.updateZone(ctx, domain, zoneStream) - return err + return c.updateZone(ctx, domain, zoneStream) } // https://github.com/InterNetX/domainrobot-api/blob/bdc8fe92a2f32fcbdb29e30bf6006ab446f81223/src/domainrobot.json#L21090 -func (c *Client) updateZone(ctx context.Context, domain string, zoneStream *ZoneStream) (*Zone, error) { +func (c *Client) updateZone(ctx context.Context, domain string, zoneStream *ZoneStream) (*DataZoneResponse, error) { endpoint := c.BaseURL.JoinPath("zone", domain, "_stream") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, zoneStream) @@ -67,12 +66,12 @@ func (c *Client) updateZone(ctx context.Context, domain string, zoneStream *Zone return nil, err } - var zone *Zone - if err := c.do(req, &zone); err != nil { + var resp *DataZoneResponse + if err := c.do(req, &resp); err != nil { return nil, err } - return zone, nil + return resp, nil } func (c *Client) do(req *http.Request, result any) error { @@ -87,7 +86,7 @@ func (c *Client) do(req *http.Request, result any) error { defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { - return errutils.NewUnexpectedResponseStatusCodeError(req, resp) + return parseError(req, resp) } if result == nil { @@ -130,3 +129,16 @@ func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, paylo return req, nil } + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/autodns/internal/client_test.go b/providers/dns/autodns/internal/client_test.go index f8743b24b..9b0968fdc 100644 --- a/providers/dns/autodns/internal/client_test.go +++ b/providers/dns/autodns/internal/client_test.go @@ -1,96 +1,174 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret", 123) + client.HTTPClient = server.Client() + client.BaseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - apiUser, apiKey, ok := req.BasicAuth() - if apiUser != "user" || apiKey != "secret" || !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client := NewClient("user", "secret", 123) - client.HTTPClient = server.Client() - client.BaseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader(). + WithBasicAuth("user", "secret"). + WithJSONHeaders()) } -func TestClient_AddTxtRecords(t *testing.T) { - client := setupTest(t, http.MethodPost, "/zone/example.com/_stream", http.StatusOK, "add-record.json") +func TestClient_AddRecords(t *testing.T) { + client := mockBuilder(). + Route("POST /zone/example.com/_stream", + servermock.ResponseFromFixture("add_record.json"), + servermock.CheckRequestJSONBodyFromFixture("add_record-request.json"), + servermock.CheckHeader(). + With("X-Domainrobot-Context", "123")). + Build(t) - records := []*ResourceRecord{{}} + records := []*ResourceRecord{{ + Name: "example.com", + TTL: 600, + Type: "TXT", + Value: "txtTXTtxt", + }} - zone, err := client.AddTxtRecords(context.Background(), "example.com", records) + resp, err := client.AddRecords(t.Context(), "example.com", records) require.NoError(t, err) - expected := &Zone{ - Name: "example.com", - ResourceRecords: []*ResourceRecord{{ - Name: "example.com", - TTL: 120, - Type: "TXT", - Value: "txt", - Pref: 1, - }}, - Action: "xxx", - VirtualNameServer: "yyy", + expected := &DataZoneResponse{ + STID: "20251121-appf4923-126284", + CTID: "", + Messages: []ResponseMessage{ + { + Text: "string", + Messages: []string{ + "string", + }, + Objects: []GenericObject{ + { + Type: "string", + Value: "string", + }, + }, + Code: "string", + Status: "SUCCESS", + }, + }, + Status: &ResponseStatus{ + Code: "S0301", + Text: "Zone was updated successfully on the name server.", + Type: "SUCCESS", + }, + Object: nil, + Data: []Zone{ + { + Name: "example.com", + ResourceRecords: []ResourceRecord{ + { + Name: "example.com", + TTL: 120, + Type: "TXT", + Value: "txt", + Pref: 1, + }, + }, + Action: "xxx", + VirtualNameServer: "yyy", + }, + }, } - assert.Equal(t, expected, zone) + assert.Equal(t, expected, resp) } -func TestClient_RemoveTXTRecords(t *testing.T) { - client := setupTest(t, http.MethodPost, "/zone/example.com/_stream", http.StatusOK, "add-record.json") +func TestClient_AddRecords_error(t *testing.T) { + client := mockBuilder(). + Route("POST /zone/example.com/_stream", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) - records := []*ResourceRecord{{}} + records := []*ResourceRecord{{ + Name: "example.com", + TTL: 600, + Type: "TXT", + Value: "txtTXTtxt", + }} - err := client.RemoveTXTRecords(context.Background(), "example.com", records) + _, err := client.AddRecords(t.Context(), "example.com", records) + require.EqualError(t, err, `STID: 20251121-appf4923-126284, status: code: E0202002, text: Zone konnte auf dem Nameserver nicht aktualisiert werden., type: ERROR, message: code: EF02022, text: Der Zusatzeintrag wurde doppelt eingetragen., status: ERROR, object: OURDOMAIN.TLD@nsa7.schlundtech.de/rr[17]: _acme-challenge.www.whoami.int.OURDOMAIN.TLD TXT "rK2SJb_ZcrYefbfCKU6jZEANfEAJeOtSh1Fv8hkUoVc"`) +} + +func TestClient_RemoveRecords(t *testing.T) { + client := mockBuilder(). + Route("POST /zone/example.com/_stream", + servermock.ResponseFromFixture("remove_record.json"), + servermock.CheckRequestJSONBodyFromFixture("remove_record-request.json"), + servermock.CheckHeader(). + With("X-Domainrobot-Context", "123")). + Build(t) + + records := []*ResourceRecord{{ + Name: "example.com", + TTL: 600, + Type: "TXT", + Value: "txtTXTtxt", + }} + + resp, err := client.RemoveRecords(t.Context(), "example.com", records) require.NoError(t, err) + + expected := &DataZoneResponse{ + STID: "20251121-appf4923-126284", + CTID: "", + Messages: []ResponseMessage{ + { + Text: "string", + Messages: []string{ + "string", + }, + Objects: []GenericObject{ + { + Type: "string", + Value: "string", + }, + }, + Code: "string", + Status: "SUCCESS", + }, + }, + Status: &ResponseStatus{ + Code: "S0301", + Text: "Zone was updated successfully on the name server.", + Type: "SUCCESS", + }, + Object: nil, + Data: []Zone{ + { + Name: "example.com", + ResourceRecords: []ResourceRecord{ + { + Name: "example.com", + TTL: 120, + Type: "TXT", + Value: "txt", + Pref: 1, + }, + }, + Action: "xxx", + VirtualNameServer: "yyy", + }, + }, + } + + assert.Equal(t, expected, resp) } diff --git a/providers/dns/autodns/internal/fixtures/add-record.json b/providers/dns/autodns/internal/fixtures/add-record.json deleted file mode 100644 index 4a95f0784..000000000 --- a/providers/dns/autodns/internal/fixtures/add-record.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "origin": "example.com", - "resourceRecords": [ - { - "name": "example.com", - "ttl": 120, - "type": "TXT", - "value": "txt", - "pref": 1 - } - ], - "action": "xxx", - "virtualNameServer": "yyy" -} diff --git a/providers/dns/autodns/internal/fixtures/add_record-request.json b/providers/dns/autodns/internal/fixtures/add_record-request.json new file mode 100644 index 000000000..6105c77ac --- /dev/null +++ b/providers/dns/autodns/internal/fixtures/add_record-request.json @@ -0,0 +1,11 @@ +{ + "adds": [ + { + "name": "example.com", + "ttl": 600, + "type": "TXT", + "value": "txtTXTtxt" + } + ], + "rems": null +} diff --git a/providers/dns/autodns/internal/fixtures/add_record.json b/providers/dns/autodns/internal/fixtures/add_record.json new file mode 100644 index 000000000..a0ce66ba6 --- /dev/null +++ b/providers/dns/autodns/internal/fixtures/add_record.json @@ -0,0 +1,41 @@ +{ + "stid": "20251121-appf4923-126284", + "messages": [ + { + "text": "string", + "notice": "string", + "messages": [ + "string" + ], + "objects": [ + { + "type": "string", + "value": "string" + } + ], + "code": "string", + "status": "SUCCESS" + } + ], + "status": { + "code": "S0301", + "text": "Zone was updated successfully on the name server.", + "type": "SUCCESS" + }, + "data": [ + { + "origin": "example.com", + "resourceRecords": [ + { + "name": "example.com", + "ttl": 120, + "type": "TXT", + "value": "txt", + "pref": 1 + } + ], + "action": "xxx", + "virtualNameServer": "yyy" + } + ] +} diff --git a/providers/dns/autodns/internal/fixtures/error.json b/providers/dns/autodns/internal/fixtures/error.json new file mode 100644 index 000000000..2ed635d58 --- /dev/null +++ b/providers/dns/autodns/internal/fixtures/error.json @@ -0,0 +1,21 @@ +{ + "stid": "20251121-appf4923-126284", + "messages": [ + { + "text": "Der Zusatzeintrag wurde doppelt eingetragen.", + "objects": [ + { + "type": "OURDOMAIN.TLD@nsa7.schlundtech.de/rr[17]", + "value": "_acme-challenge.www.whoami.int.OURDOMAIN.TLD TXT \"rK2SJb_ZcrYefbfCKU6jZEANfEAJeOtSh1Fv8hkUoVc\"" + } + ], + "code": "EF02022", + "status": "ERROR" + } + ], + "status": { + "code": "E0202002", + "text": "Zone konnte auf dem Nameserver nicht aktualisiert werden.", + "type": "ERROR" + } +} diff --git a/providers/dns/autodns/internal/fixtures/remove-record.json b/providers/dns/autodns/internal/fixtures/remove-record.json deleted file mode 100644 index 4a95f0784..000000000 --- a/providers/dns/autodns/internal/fixtures/remove-record.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "origin": "example.com", - "resourceRecords": [ - { - "name": "example.com", - "ttl": 120, - "type": "TXT", - "value": "txt", - "pref": 1 - } - ], - "action": "xxx", - "virtualNameServer": "yyy" -} diff --git a/providers/dns/autodns/internal/fixtures/remove_record-request.json b/providers/dns/autodns/internal/fixtures/remove_record-request.json new file mode 100644 index 000000000..92361403e --- /dev/null +++ b/providers/dns/autodns/internal/fixtures/remove_record-request.json @@ -0,0 +1,11 @@ +{ + "adds": null, + "rems": [ + { + "name": "example.com", + "ttl": 600, + "type": "TXT", + "value": "txtTXTtxt" + } + ] +} diff --git a/providers/dns/autodns/internal/fixtures/remove_record.json b/providers/dns/autodns/internal/fixtures/remove_record.json new file mode 100644 index 000000000..a0ce66ba6 --- /dev/null +++ b/providers/dns/autodns/internal/fixtures/remove_record.json @@ -0,0 +1,41 @@ +{ + "stid": "20251121-appf4923-126284", + "messages": [ + { + "text": "string", + "notice": "string", + "messages": [ + "string" + ], + "objects": [ + { + "type": "string", + "value": "string" + } + ], + "code": "string", + "status": "SUCCESS" + } + ], + "status": { + "code": "S0301", + "text": "Zone was updated successfully on the name server.", + "type": "SUCCESS" + }, + "data": [ + { + "origin": "example.com", + "resourceRecords": [ + { + "name": "example.com", + "ttl": 120, + "type": "TXT", + "value": "txt", + "pref": 1 + } + ], + "action": "xxx", + "virtualNameServer": "yyy" + } + ] +} diff --git a/providers/dns/autodns/internal/types.go b/providers/dns/autodns/internal/types.go index 93fd678ca..8a06f4889 100644 --- a/providers/dns/autodns/internal/types.go +++ b/providers/dns/autodns/internal/types.go @@ -1,33 +1,133 @@ package internal +import ( + "fmt" + "strings" +) + +type APIResponse[T any] struct { + STID string `json:"stid"` + CTID string `json:"ctid"` + Messages []ResponseMessage `json:"messages"` + Status *ResponseStatus `json:"status"` + Object *ResponseObject `json:"object"` + Data T `json:"data"` +} + +type APIError APIResponse[any] + +func (a *APIError) Error() string { + var parts []string + + if a.STID != "" { + parts = append(parts, fmt.Sprintf("STID: %s", a.STID)) + } + + if a.CTID != "" { + parts = append(parts, fmt.Sprintf("CTID: %s", a.CTID)) + } + + if a.Status != nil { + parts = append(parts, "status: "+a.Status.String()) + } + + for _, message := range a.Messages { + parts = append(parts, "message: "+message.String()) + } + + if a.Object != nil { + parts = append(parts, "object: "+a.Object.String()) + } + + return strings.Join(parts, ", ") +} + +type DataZoneResponse APIResponse[[]Zone] + type ResponseMessage struct { - Text string `json:"text"` - Messages []string `json:"messages"` - Objects []string `json:"objects"` - Code string `json:"code"` - Status string `json:"status"` + Text string `json:"text"` + Code string `json:"code"` + Status string `json:"status"` + Messages []string `json:"messages"` + Objects []GenericObject `json:"objects"` +} + +func (r ResponseMessage) String() string { + var parts []string + + if r.Code != "" { + parts = append(parts, "code: "+r.Code) + } + + if r.Text != "" { + parts = append(parts, "text: "+r.Text) + } + + if r.Status != "" { + parts = append(parts, "status: "+r.Status) + } + + if len(r.Messages) > 0 { + parts = append(parts, "messages: "+strings.Join(r.Messages, ";")) + } + + for _, object := range r.Objects { + parts = append(parts, fmt.Sprintf("object: %s", object)) + } + + return strings.Join(parts, ", ") +} + +type GenericObject struct { + Type string `json:"type"` + Value string `json:"value"` +} + +func (g GenericObject) String() string { + return g.Type + ": " + g.Value } type ResponseStatus struct { Code string `json:"code"` Text string `json:"text"` - Type string `json:"type"` + Type string `json:"type"` // SUCCESS, ERROR, NOTIFY, NOTICE, NICCOM_NOTIFY +} + +func (r ResponseStatus) String() string { + return fmt.Sprintf("code: %s, text: %s, type: %s", r.Code, r.Text, r.Type) } type ResponseObject struct { - Type string `json:"type"` - Value string `json:"value"` - Summary int32 `json:"summary"` - Data string + Type string `json:"type"` + Value string `json:"value"` + Summary int32 `json:"summary"` + Data *ResponseObjectData `json:"data"` } -type DataZoneResponse struct { - STID string `json:"stid"` - CTID string `json:"ctid"` - Messages []*ResponseMessage `json:"messages"` - Status *ResponseStatus `json:"status"` - Object any `json:"object"` - Data []*Zone `json:"data"` +func (r ResponseObject) String() string { + var parts []string + + if r.Type != "" { + parts = append(parts, fmt.Sprintf("type: %s", r.Type)) + } + + if r.Value != "" { + parts = append(parts, fmt.Sprintf("value: %s", r.Value)) + } + + if r.Summary != 0 { + parts = append(parts, fmt.Sprintf("summary: %d", r.Summary)) + } + + if r.Data != nil { + parts = append(parts, fmt.Sprintf("data: %s", r.Data.Description)) + } + + return strings.Join(parts, ", ") +} + +type ResponseObjectData struct { + Description string `json:"description"` } // ResourceRecord holds a resource record. @@ -43,10 +143,10 @@ type ResourceRecord struct { // Zone is an autodns zone record with all for us relevant fields. // https://help.internetx.com/display/APIXMLEN/Zone+Object type Zone struct { - Name string `json:"origin"` - ResourceRecords []*ResourceRecord `json:"resourceRecords"` - Action string `json:"action"` - VirtualNameServer string `json:"virtualNameServer"` + Name string `json:"origin"` + ResourceRecords []ResourceRecord `json:"resourceRecords"` + Action string `json:"action"` + VirtualNameServer string `json:"virtualNameServer"` } // ZoneStream body of the requests. diff --git a/providers/dns/axelname/axelname.go b/providers/dns/axelname/axelname.go new file mode 100644 index 000000000..96d26236e --- /dev/null +++ b/providers/dns/axelname/axelname.go @@ -0,0 +1,160 @@ +// Package axelname implements a DNS provider for solving the DNS-01 challenge using Axelname. +package axelname + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/axelname/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "AXELNAME_" + + EnvNickname = envNamespace + "NICKNAME" + EnvToken = envNamespace + "TOKEN" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Nickname string + Token string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Axelname. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvNickname, EnvToken) + if err != nil { + return nil, fmt.Errorf("axelname: %w", err) + } + + config := NewDefaultConfig() + config.Nickname = values[EnvNickname] + config.Token = values[EnvToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Axelname. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("axelname: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.Nickname, config.Token) + if err != nil { + return nil, fmt.Errorf("axelname: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("axelname: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("axelname: %w", err) + } + + record := internal.Record{ + Name: subDomain, + Type: "TXT", + Value: info.Value, + } + + err = d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record) + if err != nil { + return fmt.Errorf("axelname: add record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("axelname: could not find zone for domain %q: %w", domain, err) + } + + records, err := d.client.ListRecords(ctx, dns01.UnFqdn(authZone)) + if err != nil { + return fmt.Errorf("axelname: list records: %w", err) + } + + for _, record := range records { + if record.Type != "TXT" || record.Value != info.Value { + continue + } + + err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), record) + if err != nil { + return fmt.Errorf("axelname: delete record: %w", err) + } + + return nil + } + + return errors.New("axelname: delete record: record not found") +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/axelname/axelname.toml b/providers/dns/axelname/axelname.toml new file mode 100644 index 000000000..1e2ad6e72 --- /dev/null +++ b/providers/dns/axelname/axelname.toml @@ -0,0 +1,24 @@ +Name = "Axelname" +Description = '''''' +URL = "https://axelname.ru" +Code = "axelname" +Since = "v4.23.0" + +Example = ''' +AXELNAME_NICKNAME="yyy" \ +AXELNAME_TOKEN="xxx" \ +lego --dns axelname -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + AXELNAME_NICKNAME = "Account nickname" + AXELNAME_TOKEN = "API token" + [Configuration.Additional] + AXELNAME_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + AXELNAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + AXELNAME_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + AXELNAME_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://axelname.ru/static/content/files/axelname_api_rest_lite.pdf" diff --git a/providers/dns/axelname/axelname_test.go b/providers/dns/axelname/axelname_test.go new file mode 100644 index 000000000..1a8bac971 --- /dev/null +++ b/providers/dns/axelname/axelname_test.go @@ -0,0 +1,144 @@ +package axelname + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvNickname, EnvToken).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvNickname: "user", + EnvToken: "secret", + }, + }, + { + desc: "missing nickname", + envVars: map[string]string{ + EnvNickname: "", + EnvToken: "secret", + }, + expected: "axelname: some credentials information are missing: AXELNAME_NICKNAME", + }, + { + desc: "missing token", + envVars: map[string]string{ + EnvNickname: "user", + EnvToken: "", + }, + expected: "axelname: some credentials information are missing: AXELNAME_TOKEN", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "axelname: some credentials information are missing: AXELNAME_NICKNAME,AXELNAME_TOKEN", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + token string + nickname string + expected string + }{ + { + desc: "success", + nickname: "user", + token: "secret", + }, + { + desc: "missing nickname", + expected: "axelname: credentials missing", + }, + { + desc: "missing token", + expected: "axelname: credentials missing", + }, + { + desc: "missing credentials", + expected: "axelname: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Token = test.token + config.Nickname = test.nickname + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/axelname/internal/client.go b/providers/dns/axelname/internal/client.go new file mode 100644 index 000000000..f2defec87 --- /dev/null +++ b/providers/dns/axelname/internal/client.go @@ -0,0 +1,184 @@ +package internal + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + querystring "github.com/google/go-querystring/query" +) + +const statusSuccess = "success" + +const defaultBaseURL = "https://my.axelname.ru/rest/" + +// Client the Axelname API client. +type Client struct { + nickname string + token string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(nickname, token string) (*Client, error) { + if token == "" || nickname == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + nickname: nickname, + token: token, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) ListRecords(ctx context.Context, domain string) ([]Record, error) { + endpoint := c.baseURL.JoinPath("dns_list") + + query := endpoint.Query() + query.Set("domain", domain) + + endpoint.RawQuery = query.Encode() + + req, err := c.newRequest(ctx, endpoint) + if err != nil { + return nil, err + } + + var results ListResponse + + err = c.do(req, &results) + if err != nil { + return nil, err + } + + if results.Result != statusSuccess { + return nil, &results.APIError + } + + return results.List, nil +} + +func (c *Client) DeleteRecord(ctx context.Context, domain string, record Record) error { + endpoint := c.baseURL.JoinPath("dns_delete") + + values, err := querystring.Values(record) + if err != nil { + return err + } + + values.Set("domain", domain) + + endpoint.RawQuery = values.Encode() + + req, err := c.newRequest(ctx, endpoint) + if err != nil { + return err + } + + var results APIResponse + + err = c.do(req, &results) + if err != nil { + return err + } + + if results.Result != statusSuccess { + return &results.APIError + } + + return nil +} + +func (c *Client) AddRecord(ctx context.Context, domain string, record Record) error { + endpoint := c.baseURL.JoinPath("dns_add") + + values, err := querystring.Values(record) + if err != nil { + return err + } + + values.Set("domain", domain) + + endpoint.RawQuery = values.Encode() + + req, err := c.newRequest(ctx, endpoint) + if err != nil { + return err + } + + var results APIResponse + + err = c.do(req, &results) + if err != nil { + return err + } + + if results.Result != statusSuccess { + return &results.APIError + } + + return nil +} + +func (c *Client) newRequest(ctx context.Context, endpoint *url.URL) (*http.Request, error) { + query := endpoint.Query() + query.Set("token", c.token) + query.Set("nichdl", c.nickname) + + endpoint.RawQuery = query.Encode() + + return http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) +} + +func (c *Client) do(req *http.Request, result any) error { + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/axelname/internal/client_test.go b/providers/dns/axelname/internal/client_test.go new file mode 100644 index 000000000..7796f6047 --- /dev/null +++ b/providers/dns/axelname/internal/client_test.go @@ -0,0 +1,118 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupClient(server *httptest.Server) (*Client, error) { + client, err := NewClient("user", "secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client, nil +} + +func TestClient_ListRecords(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /dns_list", + servermock.ResponseFromFixture("dns_list.json"), + servermock.CheckQueryParameter().Strict(). + With("domain", "example.com"). + With("nichdl", "user"). + With("token", "secret")). + Build(t) + + records, err := client.ListRecords(t.Context(), "example.com") + require.NoError(t, err) + + expected := []Record{ + {ID: "74749", Name: "example.com", Type: "A", Value: "46.161.54.22"}, + {ID: "417", Name: "example.com", Type: "MX", Value: "mx.yandex.ru.", Prio: "10"}, + {ID: "419", Name: "mail.example.com", Type: "CNAME", Value: "mail.yandex.ru."}, + {ID: "74750", Name: "www.example.com", Type: "A", Value: "46.161.54.22"}, + } + + assert.Equal(t, expected, records) +} + +func TestClient_ListRecords_error(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /dns_list", + servermock.ResponseFromFixture("dns_list_error.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) + + _, err := client.ListRecords(t.Context(), "example.com") + require.EqualError(t, err, "error: Domain not found (1)") +} + +func TestClient_DeleteRecord(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /dns_delete", + servermock.ResponseFromFixture("dns_delete.json"), + servermock.CheckQueryParameter().Strict(). + With("id", "74749"). + With("domain", "example.com"). + With("nichdl", "user"). + With("token", "secret")). + Build(t) + + record := Record{ID: "74749"} + + err := client.DeleteRecord(t.Context(), "example.com", record) + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /dns_delete", + servermock.ResponseFromFixture("dns_delete_error.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) + + record := Record{ID: "74749"} + + err := client.DeleteRecord(t.Context(), "example.com", record) + require.EqualError(t, err, "error: Domain not found (1)") +} + +func TestClient_AddRecord(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /dns_add", + servermock.ResponseFromFixture("dns_add.json"), + servermock.CheckQueryParameter().Strict(). + With("id", "74749"). + With("domain", "example.com"). + With("nichdl", "user"). + With("token", "secret")). + Build(t) + + record := Record{ID: "74749"} + + err := client.AddRecord(t.Context(), "example.com", record) + require.NoError(t, err) +} + +func TestClient_AddRecord_error(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /dns_add", + servermock.ResponseFromFixture("dns_add_error.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) + + record := Record{ID: "74749"} + + err := client.AddRecord(t.Context(), "example.com", record) + require.EqualError(t, err, "error: Domain not found (1)") +} diff --git a/providers/dns/axelname/internal/fixtures/dns_add.json b/providers/dns/axelname/internal/fixtures/dns_add.json new file mode 100644 index 000000000..628813579 --- /dev/null +++ b/providers/dns/axelname/internal/fixtures/dns_add.json @@ -0,0 +1,5 @@ +{ + "code": "OK", + "message": "DNS record added", + "result": "success" +} diff --git a/providers/dns/axelname/internal/fixtures/dns_add_error.json b/providers/dns/axelname/internal/fixtures/dns_add_error.json new file mode 100644 index 000000000..5fb9fd368 --- /dev/null +++ b/providers/dns/axelname/internal/fixtures/dns_add_error.json @@ -0,0 +1,5 @@ +{ + "error_code": "1", + "error_text": "Domain not found", + "result": "error" +} diff --git a/providers/dns/axelname/internal/fixtures/dns_delete.json b/providers/dns/axelname/internal/fixtures/dns_delete.json new file mode 100644 index 000000000..a7851fcc6 --- /dev/null +++ b/providers/dns/axelname/internal/fixtures/dns_delete.json @@ -0,0 +1,5 @@ +{ + "code": "OK", + "message": "DNS record deleted", + "result": "success" +} diff --git a/providers/dns/axelname/internal/fixtures/dns_delete_error.json b/providers/dns/axelname/internal/fixtures/dns_delete_error.json new file mode 100644 index 000000000..5fb9fd368 --- /dev/null +++ b/providers/dns/axelname/internal/fixtures/dns_delete_error.json @@ -0,0 +1,5 @@ +{ + "error_code": "1", + "error_text": "Domain not found", + "result": "error" +} diff --git a/providers/dns/axelname/internal/fixtures/dns_list.json b/providers/dns/axelname/internal/fixtures/dns_list.json new file mode 100644 index 000000000..ace11ba73 --- /dev/null +++ b/providers/dns/axelname/internal/fixtures/dns_list.json @@ -0,0 +1,33 @@ +{ + "code": "OK", + "message": "DNS-records", + "count": 4, + "result": "success", + "list": [ + { + "id": "74749", + "name": "example.com", + "type": "A", + "value": "46.161.54.22" + }, + { + "id": "417", + "name": "example.com", + "type": "MX", + "value": "mx.yandex.ru.", + "prio": "10" + }, + { + "id": "419", + "name": "mail.example.com", + "type": "CNAME", + "value": "mail.yandex.ru." + }, + { + "id": "74750", + "name": "www.example.com", + "type": "A", + "value": "46.161.54.22" + } + ] +} diff --git a/providers/dns/axelname/internal/fixtures/dns_list_error.json b/providers/dns/axelname/internal/fixtures/dns_list_error.json new file mode 100644 index 000000000..5fb9fd368 --- /dev/null +++ b/providers/dns/axelname/internal/fixtures/dns_list_error.json @@ -0,0 +1,5 @@ +{ + "error_code": "1", + "error_text": "Domain not found", + "result": "error" +} diff --git a/providers/dns/axelname/internal/types.go b/providers/dns/axelname/internal/types.go new file mode 100644 index 000000000..45583fb2e --- /dev/null +++ b/providers/dns/axelname/internal/types.go @@ -0,0 +1,35 @@ +package internal + +import "fmt" + +type APIError struct { + ErrorCode string `json:"error_code,omitempty"` + ErrorText string `json:"error_text,omitempty"` + Result string `json:"result,omitempty"` +} + +func (a *APIError) Error() string { + return fmt.Sprintf("%s: %s (%s)", a.Result, a.ErrorText, a.ErrorCode) +} + +type APIResponse struct { + APIError + + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +type ListResponse struct { + APIResponse + + Count int `json:"count,omitempty"` + List []Record `json:"list,omitempty"` +} + +type Record struct { + ID string `json:"id,omitempty" url:"id,omitempty"` + Name string `json:"name,omitempty" url:"name,omitempty"` + Type string `json:"type,omitempty" url:"type,omitempty"` + Value string `json:"value,omitempty" url:"value,omitempty"` + Prio string `json:"prio,omitempty" url:"prio,omitempty"` +} diff --git a/providers/dns/azion/azion.go b/providers/dns/azion/azion.go new file mode 100644 index 000000000..5584ece0b --- /dev/null +++ b/providers/dns/azion/azion.go @@ -0,0 +1,307 @@ +// Package azion implements a DNS provider for solving the DNS-01 challenge using Azion Edge DNS. +package azion + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/aziontech/azionapi-go-sdk/idns" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "AZION_" + + EnvPersonalToken = envNamespace + "PERSONAL_TOKEN" + EnvPageSize = envNamespace + "PAGE_SIZE" + + EnvTTL = envNamespace + "TTL" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + PersonalToken string + PageSize int + + PollingInterval time.Duration + PropagationTimeout time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + PageSize: env.GetOrDefaultInt(EnvPageSize, 50), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *idns.APIClient +} + +// NewDNSProvider returns a DNSProvider instance configured for Azion. +// Credentials must be passed in the environment variable: AZION_PERSONAL_TOKEN. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvPersonalToken) + if err != nil { + return nil, fmt.Errorf("azion: %w", err) + } + + config := NewDefaultConfig() + config.PersonalToken = values[EnvPersonalToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Azion. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("azion: the configuration of the DNS provider is nil") + } + + if config.PersonalToken == "" { + return nil, errors.New("azion: missing credentials") + } + + clientConfig := idns.NewConfiguration() + clientConfig.AddDefaultHeader("Accept", "application/json; version=3") + clientConfig.UserAgent = "lego-dns/azion" + + if config.HTTPClient != nil { + clientConfig.HTTPClient = config.HTTPClient + } + + clientConfig.HTTPClient = clientdebug.Wrap(clientConfig.HTTPClient) + + client := idns.NewAPIClient(clientConfig) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctxAuth := authContext(context.Background(), d.config.PersonalToken) + + zone, err := d.findZone(ctxAuth, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("azion: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := extractSubDomain(info, zone) + if err != nil { + return fmt.Errorf("azion: %w", err) + } + + // Check if a TXT record with the same name already exists + existingRecord, err := d.findExistingTXTRecord(ctxAuth, zone.GetId(), subDomain) + if err != nil { + return fmt.Errorf("azion: check existing records: %w", err) + } + + record := idns.NewRecordPostOrPut() + record.SetEntry(subDomain) + record.SetRecordType("TXT") + record.SetTtl(int32(d.config.TTL)) + + var resp *idns.PostOrPutRecordResponse + + if existingRecord != nil { + // Update existing record by adding the new value to the existing ones + record.SetAnswersList(append(existingRecord.GetAnswersList(), info.Value)) + + // Use PUT to update the existing record + resp, _, err = d.client.RecordsAPI.PutZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).RecordPostOrPut(*record).Execute() + if err != nil { + return fmt.Errorf("azion: update existing record: %w", err) + } + } else { + // Create a new record + record.SetAnswersList([]string{info.Value}) + + resp, _, err = d.client.RecordsAPI.PostZoneRecord(ctxAuth, zone.GetId()).RecordPostOrPut(*record).Execute() + if err != nil { + return fmt.Errorf("azion: create new zone record: %w", err) + } + } + + if resp == nil || resp.Results == nil { + return errors.New("azion: create zone record error") + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctxAuth := authContext(context.Background(), d.config.PersonalToken) + + zone, err := d.findZone(ctxAuth, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("azion: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := extractSubDomain(info, zone) + if err != nil { + return fmt.Errorf("azion: %w", err) + } + + existingRecord, err := d.findExistingTXTRecord(ctxAuth, zone.GetId(), subDomain) + if err != nil { + return fmt.Errorf("azion: find existing record: %w", err) + } + + if existingRecord == nil { + return nil + } + + currentAnswers := existingRecord.GetAnswersList() + + var updatedAnswers []string + + for _, answer := range currentAnswers { + if answer != info.Value { + updatedAnswers = append(updatedAnswers, answer) + } + } + + // If no answers remain, delete the entire record + if len(updatedAnswers) == 0 { + _, resp, errDelete := d.client.RecordsAPI.DeleteZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).Execute() + if errDelete != nil { + // If a record doesn't exist (404), consider cleanup successful + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil + } + + return fmt.Errorf("azion: delete record: %w", errDelete) + } + + return nil + } + + // Update the record with remaining answers + record := idns.NewRecordPostOrPut() + record.SetEntry(subDomain) + record.SetRecordType("TXT") + record.SetAnswersList(updatedAnswers) + record.SetTtl(existingRecord.GetTtl()) + + _, _, err = d.client.RecordsAPI.PutZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).RecordPostOrPut(*record).Execute() + if err != nil { + return fmt.Errorf("azion: update record: %w", err) + } + + return nil +} + +func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*idns.Zone, error) { + resp, _, err := d.client.ZonesAPI.GetZones(ctx).Execute() + if err != nil { + return nil, fmt.Errorf("get zones: %w", err) + } + + if resp == nil { + return nil, errors.New("get zones: no results") + } + + for domain := range dns01.UnFqdnDomainsSeq(fqdn) { + for _, zone := range resp.GetResults() { + if zone.GetDomain() == domain { + return &zone, nil + } + } + } + + return nil, fmt.Errorf("zone not found (fqdn: %q)", fqdn) +} + +// findExistingTXTRecord searches for an existing TXT record with the given name in the specified zone. +// It handles pagination to search through all pages of results. +func (d *DNSProvider) findExistingTXTRecord(ctx context.Context, zoneID int32, recordName string) (*idns.RecordGet, error) { + var page int64 = 1 + + for { + resp, _, err := d.client.RecordsAPI.GetZoneRecords(ctx, zoneID).Page(page).PageSize(int64(d.config.PageSize)).Execute() + if err != nil { + return nil, fmt.Errorf("get zone records (page %d): %w", page, err) + } + + if resp == nil { + return nil, errors.New("get zone records: no results") + } + + results, ok := resp.GetResultsOk() + if !ok || results == nil { + return nil, errors.New("get zone records: empty") + } + + // Search for existing TXT record with the same name in current page + for _, record := range results.GetRecords() { + if record.GetRecordType() == "TXT" && record.GetEntry() == recordName { + return &record, nil + } + } + + // Check if there are more pages to search + if page >= int64(resp.GetTotalPages()) { + break + } + + page++ + } + + // No existing record found in any page + return nil, nil +} + +func authContext(ctx context.Context, key string) context.Context { + return context.WithValue(ctx, idns.ContextAPIKeys, map[string]idns.APIKey{ + "tokenAuth": { + Key: key, + Prefix: "Token", + }, + }) +} + +func extractSubDomain(info dns01.ChallengeInfo, zone *idns.Zone) (string, error) { + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.GetName()) + if err != nil { + return "", err + } + + if subDomain != "" { + return subDomain, nil + } + + return "@", nil +} diff --git a/providers/dns/azion/azion.toml b/providers/dns/azion/azion.toml new file mode 100644 index 000000000..52df20ab5 --- /dev/null +++ b/providers/dns/azion/azion.toml @@ -0,0 +1,24 @@ +Name = "Azion" +Description = '''''' +Code = "azion" +Since = "v4.24.0" +URL = "https://www.azion.com/en/products/edge-dns/" + +Example = ''' +AZION_PERSONAL_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxx \ +lego --dns azion -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + AZION_PERSONAL_TOKEN = "Your Azion personal token." + [Configuration.Additional] + AZION_PAGE_SIZE = "The page size for the API request (Default: 50)" + AZION_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + AZION_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + AZION_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + AZION_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://api.azion.com/" + GoClient = "https://github.com/aziontech/azionapi-go-sdk" diff --git a/providers/dns/azion/azion_test.go b/providers/dns/azion/azion_test.go new file mode 100644 index 000000000..517594cdc --- /dev/null +++ b/providers/dns/azion/azion_test.go @@ -0,0 +1,235 @@ +package azion + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/aziontech/azionapi-go-sdk/idns" + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvPersonalToken).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvPersonalToken: "token", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvPersonalToken: "", + }, + expected: "azion: some credentials information are missing: AZION_PERSONAL_TOKEN", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + token string + expected string + }{ + { + desc: "success", + token: "token", + }, + { + desc: "missing credentials", + expected: "azion: missing credentials", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.PersonalToken = test.token + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_findZone(t *testing.T) { + provider := mockBuilder(). + Route("GET /intelligent_dns", servermock.ResponseFromFixture("zones.json")). + Build(t) + + testCases := []struct { + desc string + fqdn string + expected *idns.Zone + }{ + { + desc: "apex", + fqdn: "example.com.", + expected: &idns.Zone{ + Id: idns.PtrInt32(1), + Domain: idns.PtrString("example.com"), + }, + }, + { + desc: "sub domain", + fqdn: "sub.example.com.", + expected: &idns.Zone{ + Id: idns.PtrInt32(2), + Domain: idns.PtrString("sub.example.com"), + }, + }, + { + desc: "long sub domain", + fqdn: "_acme-challenge.api.sub.example.com.", + expected: &idns.Zone{ + Id: idns.PtrInt32(2), + Domain: idns.PtrString("sub.example.com"), + }, + }, + { + desc: "long sub domain, apex", + fqdn: "_acme-challenge.test.example.com.", + expected: &idns.Zone{ + Id: idns.PtrInt32(1), + Domain: idns.PtrString("example.com"), + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + zone, err := provider.findZone(context.Background(), test.fqdn) + require.NoError(t, err) + + assert.Equal(t, test.expected, zone) + }) + } +} + +func TestDNSProvider_findZone_error(t *testing.T) { + testCases := []struct { + desc string + fqdn string + response string + expected string + }{ + { + desc: "no parent zone found", + fqdn: "_acme-challenge.example.org.", + response: "zones.json", + expected: `zone not found (fqdn: "_acme-challenge.example.org.")`, + }, + { + desc: "empty zones list", + fqdn: "example.com.", + response: "zones_empty.json", + expected: `zone not found (fqdn: "example.com.")`, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + provider := mockBuilder(). + Route("GET /intelligent_dns", servermock.ResponseFromFixture(test.response)). + Build(t) + + zone, err := provider.findZone(context.Background(), test.fqdn) + require.EqualError(t, err, test.expected) + + assert.Nil(t, zone) + }) + } +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.PersonalToken = "secret" + + provider, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + clientConfig := provider.client.GetConfig() + clientConfig.HTTPClient = server.Client() + clientConfig.Servers = idns.ServerConfigurations{{ + URL: server.URL, + Description: "Production", + }} + + return provider, nil + }, + ) +} diff --git a/providers/dns/azion/fixtures/zones.json b/providers/dns/azion/fixtures/zones.json new file mode 100644 index 000000000..7dccedf1a --- /dev/null +++ b/providers/dns/azion/fixtures/zones.json @@ -0,0 +1,19 @@ +{ + "count": 2, + "links": { + "previous": null, + "next": null + }, + "total_pages": 1, + "results": [ + { + "id": 1, + "domain": "example.com" + }, + { + "id": 2, + "domain": "sub.example.com" + } + ], + "schema_version": 3 +} diff --git a/providers/dns/azion/fixtures/zones_empty.json b/providers/dns/azion/fixtures/zones_empty.json new file mode 100644 index 000000000..540063837 --- /dev/null +++ b/providers/dns/azion/fixtures/zones_empty.json @@ -0,0 +1,10 @@ +{ + "count": 0, + "links": { + "previous": null, + "next": null + }, + "total_pages": 0, + "results": null, + "schema_version": 3 +} diff --git a/providers/dns/azure/azure.go b/providers/dns/azure/azure.go index bb5a741d2..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" @@ -18,8 +19,6 @@ import ( "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) -const defaultMetadataEndpoint = "http://169.254.169.254" - // Environment variables names. const ( envNamespace = "AZURE_" @@ -39,6 +38,12 @@ const ( EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +const EnvLegoAzureBypassDeprecation = "LEGO_AZURE_BYPASS_DEPRECATION" + +const defaultMetadataEndpoint = "http://169.254.169.254" + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { ZoneName string @@ -87,6 +92,7 @@ type DNSProvider struct { // If the credentials are _not_ set via the environment, // then it will attempt to get a bearer token via the instance metadata service. // see: https://github.com/Azure/go-autorest/blob/v10.14.0/autorest/azure/auth/auth.go#L38-L42 +// // Deprecated: use azuredns instead. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() @@ -94,6 +100,7 @@ func NewDNSProvider() (*DNSProvider, error) { environmentName := env.GetOrFile(EnvEnvironment) if environmentName != "" { var environment aazure.Environment + switch environmentName { case "china": environment = aazure.ChinaCloud @@ -122,12 +129,25 @@ func NewDNSProvider() (*DNSProvider, error) { } // NewDNSProviderConfig return a DNSProvider instance configured for Azure. +// // Deprecated: use azuredns instead. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("azure: the configuration of the DNS provider is nil") } + if !env.GetOrDefaultBool(EnvLegoAzureBypassDeprecation, false) { + var msg strings.Builder + + msg.WriteString("azure: ") + msg.WriteString("The `azure` provider has been deprecated since 2023, and replaced by `azuredns` provider. ") + msg.WriteString("It can be TEMPORARILY reactivated by using the environment variable `LEGO_AZURE_BYPASS_DEPRECATION=true`. ") + msg.WriteString("The `azure` provider will be removed in a future release, please migrate to the `azuredns` provider. ") + msg.WriteString("The documentation of the `azuredns` provider can be found at https://go-acme.github.io/lego/dns/azuredns/") + + return nil, errors.New(msg.String()) + } + if config.HTTPClient == nil { config.HTTPClient = &http.Client{Timeout: 5 * time.Second} } @@ -146,6 +166,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if subsID == "" { return nil, errors.New("azure: SubscriptionID is missing") } + config.SubscriptionID = subsID } @@ -158,6 +179,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if resGroup == "" { return nil, errors.New("azure: ResourceGroup is missing") } + config.ResourceGroup = resGroup } diff --git a/providers/dns/azure/azure.toml b/providers/dns/azure/azure.toml index c4e3b674a..a38ed55ab 100644 --- a/providers/dns/azure/azure.toml +++ b/providers/dns/azure/azure.toml @@ -19,9 +19,9 @@ Example = '''''' AZURE_METADATA_ENDPOINT = "Metadata Service endpoint URL" AZURE_PRIVATE_ZONE = "Set to true to use Azure Private DNS Zones and not public" AZURE_ZONE_NAME = "Zone name to use inside Azure DNS service to add the TXT record in" - AZURE_POLLING_INTERVAL = "Time between DNS propagation check" - AZURE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - AZURE_TTL = "The TTL of the TXT record used for the DNS challenge" + AZURE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + AZURE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + AZURE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" [Links] API = "https://docs.microsoft.com/en-us/go/azure/" diff --git a/providers/dns/azure/azure_test.go b/providers/dns/azure/azure_test.go index 496168362..c4fec4359 100644 --- a/providers/dns/azure/azure_test.go +++ b/providers/dns/azure/azure_test.go @@ -14,6 +14,7 @@ import ( const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( + EnvLegoAzureBypassDeprecation, EnvEnvironment, EnvClientID, EnvClientSecret, @@ -54,8 +55,11 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() + test.envVars[EnvLegoAzureBypassDeprecation] = "true" + envTest.Apply(test.envVars) p, err := NewDNSProvider() @@ -139,6 +143,11 @@ func TestNewDNSProviderConfig(t *testing.T) { }, } + defer envTest.RestoreEnv() + + envTest.ClearEnv() + envTest.Apply(map[string]string{EnvLegoAzureBypassDeprecation: "true"}) + for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() @@ -158,6 +167,7 @@ func TestNewDNSProviderConfig(t *testing.T) { } else { mux.HandleFunc("/", test.handler) } + config.MetadataEndpoint = server.URL p, err := NewDNSProviderConfig(config) @@ -186,6 +196,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -199,6 +210,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/azure/private.go b/providers/dns/azure/private.go index d6c9fc7bd..f7c6a75b7 100644 --- a/providers/dns/azure/private.go +++ b/providers/dns/azure/private.go @@ -54,6 +54,7 @@ func (d *dnsProviderPrivate) Present(domain, token, keyAuth string) error { // Construct unique TXT records using map uniqRecords := map[string]struct{}{info.Value: {}} + if rset.RecordSetProperties != nil && rset.TxtRecords != nil { for _, txtRecord := range *rset.TxtRecords { // Assume Value doesn't contain multiple strings @@ -81,6 +82,7 @@ func (d *dnsProviderPrivate) Present(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("azure: %w", err) } + return nil } @@ -106,6 +108,7 @@ func (d *dnsProviderPrivate) CleanUp(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("azure: %w", err) } + return nil } diff --git a/providers/dns/azure/public.go b/providers/dns/azure/public.go index 8e6fa182a..194956c9c 100644 --- a/providers/dns/azure/public.go +++ b/providers/dns/azure/public.go @@ -54,6 +54,7 @@ func (d *dnsProviderPublic) Present(domain, token, keyAuth string) error { // Construct unique TXT records using map uniqRecords := map[string]struct{}{info.Value: {}} + if rset.RecordSetProperties != nil && rset.TxtRecords != nil { for _, txtRecord := range *rset.TxtRecords { // Assume Value doesn't contain multiple strings @@ -81,6 +82,7 @@ func (d *dnsProviderPublic) Present(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("azure: %w", err) } + return nil } @@ -106,6 +108,7 @@ func (d *dnsProviderPublic) CleanUp(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("azure: %w", err) } + return nil } diff --git a/providers/dns/azuredns/azuredns.go b/providers/dns/azuredns/azuredns.go index 765bd0730..b8effadea 100644 --- a/providers/dns/azuredns/azuredns.go +++ b/providers/dns/azuredns/azuredns.go @@ -3,20 +3,15 @@ package azuredns import ( - "context" "errors" "fmt" "net/http" - "strings" "time" - "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" - "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/go-acme/lego/v4/challenge" - "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -33,10 +28,21 @@ const ( EnvClientID = envNamespace + "CLIENT_ID" EnvClientSecret = envNamespace + "CLIENT_SECRET" - EnvOIDCToken = envNamespace + "OIDC_TOKEN" - EnvOIDCTokenFilePath = envNamespace + "OIDC_TOKEN_FILE_PATH" - EnvOIDCRequestURL = envNamespace + "OIDC_REQUEST_URL" - EnvOIDCRequestToken = envNamespace + "OIDC_REQUEST_TOKEN" + EnvOIDCToken = envNamespace + "OIDC_TOKEN" + EnvOIDCTokenFilePath = envNamespace + "OIDC_TOKEN_FILE_PATH" + EnvOIDCRequestURL = envNamespace + "OIDC_REQUEST_URL" + EnvGitHubOIDCRequestURL = "ACTIONS_ID_TOKEN_REQUEST_URL" + altEnvArmOIDCRequestURL = "ARM_OIDC_REQUEST_URL" + EnvOIDCRequestToken = envNamespace + "OIDC_REQUEST_TOKEN" + EnvGitHubOIDCRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN" + altEnvArmOIDCRequestToken = "ARM_OIDC_REQUEST_TOKEN" + + EnvServiceConnectionID = envNamespace + "SERVICE_CONNECTION_ID" + altEnvServiceConnectionID = "SERVICE_CONNECTION_ID" + altEnvArmAdoPipelineServiceConnectionID = "ARM_ADO_PIPELINE_SERVICE_CONNECTION_ID" + altEnvArmOIDCAzureServiceConnectionID = "ARM_OIDC_AZURE_SERVICE_CONNECTION_ID" + EnvSystemAccessToken = envNamespace + "SYSTEM_ACCESS_TOKEN" + altEnvSystemAccessToken = "SYSTEM_ACCESSTOKEN" EnvAuthMethod = envNamespace + "AUTH_METHOD" EnvAuthMSITimeout = envNamespace + "AUTH_MSI_TIMEOUT" @@ -46,11 +52,10 @@ const ( EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" - - EnvGitHubOIDCRequestURL = "ACTIONS_ID_TOKEN_REQUEST_URL" - EnvGitHubOIDCRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { ZoneName string @@ -71,6 +76,9 @@ type Config struct { OIDCRequestURL string OIDCRequestToken string + ServiceConnectionID string + SystemAccessToken string + AuthMethod string AuthMSITimeout time.Duration @@ -132,13 +140,22 @@ func NewDNSProvider() (*DNSProvider, error) { config.ServiceDiscoveryFilter = env.GetOrFile(EnvServiceDiscoveryFilter) oidcValues, _ := env.GetWithFallback( - []string{EnvOIDCRequestURL, EnvGitHubOIDCRequestURL}, - []string{EnvOIDCRequestToken, EnvGitHubOIDCRequestToken}, + []string{EnvOIDCRequestURL, EnvGitHubOIDCRequestURL, altEnvArmOIDCRequestURL}, + []string{EnvOIDCRequestToken, EnvGitHubOIDCRequestToken, altEnvArmOIDCRequestToken}, ) config.OIDCRequestURL = oidcValues[EnvOIDCRequestURL] config.OIDCRequestToken = oidcValues[EnvOIDCRequestToken] + // https://registry.terraform.io/providers/hashicorp/Azurerm/latest/docs/guides/service_principal_oidc + pipelineValues, _ := env.GetWithFallback( + []string{EnvServiceConnectionID, altEnvServiceConnectionID, altEnvArmAdoPipelineServiceConnectionID, altEnvArmOIDCAzureServiceConnectionID}, + []string{EnvSystemAccessToken, altEnvArmOIDCRequestToken, altEnvSystemAccessToken}, + ) + + config.ServiceConnectionID = pipelineValues[EnvServiceConnectionID] + config.SystemAccessToken = pipelineValues[EnvSystemAccessToken] + config.AuthMethod = env.GetOrFile(EnvAuthMethod) config.AuthMSITimeout = env.GetOrDefaultSecond(EnvAuthMSITimeout, 2*time.Second) @@ -155,6 +172,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { config.HTTPClient = &http.Client{Timeout: 5 * time.Second} } + config.HTTPClient = clientdebug.Wrap(config.HTTPClient) + credentials, err := getCredentials(config) if err != nil { return nil, fmt.Errorf("azuredns: Unable to retrieve valid credentials: %w", err) @@ -191,97 +210,3 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return d.provider.CleanUp(domain, token, keyAuth) } - -func getCredentials(config *Config) (azcore.TokenCredential, error) { - clientOptions := azcore.ClientOptions{Cloud: config.Environment} - - switch strings.ToLower(config.AuthMethod) { - case "env": - if config.ClientID != "" && config.ClientSecret != "" && config.TenantID != "" { - return azidentity.NewClientSecretCredential(config.TenantID, config.ClientID, config.ClientSecret, - &azidentity.ClientSecretCredentialOptions{ClientOptions: clientOptions}) - } - - return azidentity.NewEnvironmentCredential(&azidentity.EnvironmentCredentialOptions{ClientOptions: clientOptions}) - - case "wli": - return azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{ClientOptions: clientOptions}) - - case "msi": - cred, err := azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{ClientOptions: clientOptions}) - if err != nil { - return nil, err - } - - return &timeoutTokenCredential{cred: cred, timeout: config.AuthMSITimeout}, nil - - case "cli": - var credOptions *azidentity.AzureCLICredentialOptions - if config.TenantID != "" { - credOptions = &azidentity.AzureCLICredentialOptions{TenantID: config.TenantID} - } - return azidentity.NewAzureCLICredential(credOptions) - - case "oidc": - err := checkOIDCConfig(config) - if err != nil { - return nil, err - } - - return azidentity.NewClientAssertionCredential(config.TenantID, config.ClientID, getOIDCAssertion(config), &azidentity.ClientAssertionCredentialOptions{ClientOptions: clientOptions}) - - default: - return azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ClientOptions: clientOptions}) - } -} - -// timeoutTokenCredential wraps a TokenCredential to add a timeout. -type timeoutTokenCredential struct { - cred azcore.TokenCredential - timeout time.Duration -} - -// GetToken implements the azcore.TokenCredential interface. -func (w *timeoutTokenCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) { - if w.timeout <= 0 { - return w.cred.GetToken(ctx, opts) - } - - ctxTimeout, cancel := context.WithTimeout(ctx, w.timeout) - defer cancel() - - tk, err := w.cred.GetToken(ctxTimeout, opts) - if ce := ctxTimeout.Err(); errors.Is(ce, context.DeadlineExceeded) { - return tk, azidentity.NewCredentialUnavailableError("managed identity timed out") - } - - w.timeout = 0 - - return tk, err -} - -func getZoneName(config *Config, fqdn string) (string, error) { - if config.ZoneName != "" { - return config.ZoneName, nil - } - - authZone, err := dns01.FindZoneByFqdn(fqdn) - if err != nil { - return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err) - } - - if authZone == "" { - return "", errors.New("empty zone name") - } - - return authZone, nil -} - -func deref[T any](v *T) T { - if v == nil { - var zero T - return zero - } - - return *v -} diff --git a/providers/dns/azuredns/azuredns.toml b/providers/dns/azuredns/azuredns.toml index 1f160a856..7c800ce7e 100644 --- a/providers/dns/azuredns/azuredns.toml +++ b/providers/dns/azuredns/azuredns.toml @@ -10,32 +10,32 @@ Example = ''' AZURE_CLIENT_ID= \ AZURE_TENANT_ID= \ AZURE_CLIENT_SECRET= \ -lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run +lego --dns azuredns -d '*.example.com' -d example.com run ### Using client certificate AZURE_CLIENT_ID= \ AZURE_TENANT_ID= \ AZURE_CLIENT_CERTIFICATE_PATH= \ -lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run +lego --dns azuredns -d '*.example.com' -d example.com run ### Using Azure CLI az login \ -lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run +lego --dns azuredns -d '*.example.com' -d example.com run ### Using Managed Identity (Azure VM) AZURE_TENANT_ID= \ AZURE_RESOURCE_GROUP= \ -lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run +lego --dns azuredns -d '*.example.com' -d example.com run ### Using Managed Identity (Azure Arc) AZURE_TENANT_ID= \ IMDS_ENDPOINT=http://localhost:40342 \ IDENTITY_ENDPOINT=http://localhost:40342/metadata/identity/oauth2/token \ -lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run +lego --dns azuredns -d '*.example.com' -d example.com run ''' @@ -174,6 +174,10 @@ This authentication method can be specifically used by setting the `AZURE_AUTH_M Open ID Connect is a mechanism that establish a trust relationship between a running environment and the Azure AD identity provider. It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `oidc`. +### Azure DevOps Pipelines + +It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `pipeline`. + ''' [Configuration] @@ -191,9 +195,9 @@ It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `oi AZURE_ZONE_NAME = "Zone name to use inside Azure DNS service to add the TXT record in" AZURE_AUTH_METHOD = "Specify which authentication method to use" AZURE_AUTH_MSI_TIMEOUT = "Managed Identity timeout duration" - AZURE_TTL = "The TTL of the TXT record used for the DNS challenge" - AZURE_POLLING_INTERVAL = "Time between DNS propagation check" - AZURE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + AZURE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + AZURE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + AZURE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" [Links] API = "https://docs.microsoft.com/en-us/go/azure/" diff --git a/providers/dns/azuredns/azuredns_test.go b/providers/dns/azuredns/azuredns_test.go index 7ddb4de45..594a0d6a3 100644 --- a/providers/dns/azuredns/azuredns_test.go +++ b/providers/dns/azuredns/azuredns_test.go @@ -35,6 +35,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -61,6 +62,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -74,6 +76,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/azuredns/credentials.go b/providers/dns/azuredns/credentials.go new file mode 100644 index 000000000..a38b3f7dd --- /dev/null +++ b/providers/dns/azuredns/credentials.go @@ -0,0 +1,136 @@ +package azuredns + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/go-acme/lego/v4/challenge/dns01" +) + +const ( + authMethodEnv = "env" + authMethodWLI = "wli" + authMethodMSI = "msi" + authMethodCLI = "cli" + authMethodOIDC = "oidc" + authMethodPipeline = "pipeline" +) + +//nolint:gocyclo // The complexity is related to the number of possible configurations. +func getCredentials(config *Config) (azcore.TokenCredential, error) { + clientOptions := azcore.ClientOptions{Cloud: config.Environment} + + switch strings.ToLower(config.AuthMethod) { + case authMethodEnv: + if config.ClientID != "" && config.ClientSecret != "" && config.TenantID != "" { + return azidentity.NewClientSecretCredential(config.TenantID, config.ClientID, config.ClientSecret, + &azidentity.ClientSecretCredentialOptions{ClientOptions: clientOptions}) + } + + return azidentity.NewEnvironmentCredential(&azidentity.EnvironmentCredentialOptions{ClientOptions: clientOptions}) + + case authMethodWLI: + return azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{ClientOptions: clientOptions}) + + case authMethodMSI: + cred, err := azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{ClientOptions: clientOptions}) + if err != nil { + return nil, err + } + + return &timeoutTokenCredential{cred: cred, timeout: config.AuthMSITimeout}, nil + + case authMethodCLI: + var credOptions *azidentity.AzureCLICredentialOptions + if config.TenantID != "" { + credOptions = &azidentity.AzureCLICredentialOptions{TenantID: config.TenantID} + } + + return azidentity.NewAzureCLICredential(credOptions) + + case authMethodOIDC: + err := checkOIDCConfig(config) + if err != nil { + return nil, err + } + + return azidentity.NewClientAssertionCredential(config.TenantID, config.ClientID, getOIDCAssertion(config), &azidentity.ClientAssertionCredentialOptions{ClientOptions: clientOptions}) + + case authMethodPipeline: + err := checkPipelineConfig(config) + if err != nil { + return nil, err + } + + // Uses the env var `SYSTEM_OIDCREQUESTURI`, + // but the constant is not exported, + // and there is no way to set it programmatically. + // https://github.com/Azure/azure-sdk-for-go/blob/aae2fb75ffccafc669db72bebc3c1a66332f48d7/sdk/azidentity/azure_pipelines_credential.go#L22 + // https://github.com/Azure/azure-sdk-for-go/blob/aae2fb75ffccafc669db72bebc3c1a66332f48d7/sdk/azidentity/azure_pipelines_credential.go#L79 + + return azidentity.NewAzurePipelinesCredential(config.TenantID, config.ClientID, config.ServiceConnectionID, config.SystemAccessToken, &azidentity.AzurePipelinesCredentialOptions{ClientOptions: clientOptions}) + + default: + return azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ClientOptions: clientOptions}) + } +} + +// timeoutTokenCredential wraps a TokenCredential to add a timeout. +type timeoutTokenCredential struct { + cred azcore.TokenCredential + timeout time.Duration +} + +// GetToken implements the azcore.TokenCredential interface. +func (w *timeoutTokenCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) { + if w.timeout <= 0 { + return w.cred.GetToken(ctx, opts) + } + + ctxTimeout, cancel := context.WithTimeout(ctx, w.timeout) + defer cancel() + + tk, err := w.cred.GetToken(ctxTimeout, opts) + if ce := ctxTimeout.Err(); errors.Is(ce, context.DeadlineExceeded) { + return tk, azidentity.NewCredentialUnavailableError("managed identity timed out") + } + + w.timeout = 0 + + return tk, err +} + +func getZoneName(config *Config, fqdn string) (string, error) { + if config.ZoneName != "" { + return config.ZoneName, nil + } + + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err) + } + + if authZone == "" { + return "", errors.New("empty zone name") + } + + return authZone, nil +} + +func checkPipelineConfig(config *Config) error { + if config.ServiceConnectionID == "" { + return errors.New("azuredns: ServiceConnectionID is missing") + } + + if config.SystemAccessToken == "" { + return errors.New("azuredns: SystemAccessToken is missing") + } + + return nil +} diff --git a/providers/dns/azuredns/private.go b/providers/dns/azuredns/private.go index d31d20b0a..43b39ed14 100644 --- a/providers/dns/azuredns/private.go +++ b/providers/dns/azuredns/private.go @@ -12,9 +12,13 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" ) +var _ challenge.ProviderTimeout = (*DNSProviderPrivate)(nil) + // DNSProviderPrivate implements the challenge.Provider interface for Azure Private Zone DNS. type DNSProviderPrivate struct { config *Config @@ -177,11 +181,12 @@ func (c privateZoneClient) Delete(ctx context.Context, subDomain string) (armpri func privateUniqueRecords(recordSet armprivatedns.RecordSet, value string) map[string]struct{} { uniqRecords := map[string]struct{}{value: {}} + if recordSet.Properties != nil && recordSet.Properties.TxtRecords != nil { for _, txtRecord := range recordSet.Properties.TxtRecords { // Assume Value doesn't contain multiple strings if len(txtRecord.Value) > 0 { - uniqRecords[deref(txtRecord.Value[0])] = struct{}{} + uniqRecords[ptr.Deref(txtRecord.Value[0])] = struct{}{} } } } diff --git a/providers/dns/azuredns/public.go b/providers/dns/azuredns/public.go index abe269705..79b6e783f 100644 --- a/providers/dns/azuredns/public.go +++ b/providers/dns/azuredns/public.go @@ -12,9 +12,13 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" ) +var _ challenge.ProviderTimeout = (*DNSProviderPublic)(nil) + // DNSProviderPublic implements the challenge.Provider interface for Azure Public Zone DNS. type DNSProviderPublic struct { config *Config @@ -175,11 +179,12 @@ func (c publicZoneClient) Delete(ctx context.Context, subDomain string) (armdns. func publicUniqueRecords(recordSet armdns.RecordSet, value string) map[string]struct{} { uniqRecords := map[string]struct{}{value: {}} + if recordSet.Properties != nil && recordSet.Properties.TxtRecords != nil { for _, txtRecord := range recordSet.Properties.TxtRecords { // Assume Value doesn't contain multiple strings if len(txtRecord.Value) > 0 { - uniqRecords[deref(txtRecord.Value[0])] = struct{}{} + uniqRecords[ptr.Deref(txtRecord.Value[0])] = struct{}{} } } } diff --git a/providers/dns/azuredns/servicediscovery.go b/providers/dns/azuredns/servicediscovery.go index 62dfd6623..50a41da37 100644 --- a/providers/dns/azuredns/servicediscovery.go +++ b/providers/dns/azuredns/servicediscovery.go @@ -9,6 +9,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" ) type ServiceDiscoveryZone struct { @@ -45,6 +46,7 @@ func discoverDNSZones(ctx context.Context, config *Config, credentials azcore.To } zones := map[string]ServiceDiscoveryZone{} + for { // create the query request request := armresourcegraph.QueryRequest{ @@ -88,7 +90,7 @@ func discoverDNSZones(ctx context.Context, config *Config, credentials azcore.To *requestOptions.Skip += ResourceGraphQueryOptionsTop if result.TotalRecords != nil { - if int64(deref(requestOptions.Skip)) >= deref(result.TotalRecords) { + if int64(ptr.Deref(requestOptions.Skip)) >= ptr.Deref(result.TotalRecords) { break } } diff --git a/providers/dns/baiducloud/baiducloud.go b/providers/dns/baiducloud/baiducloud.go new file mode 100644 index 000000000..1dc8d90ed --- /dev/null +++ b/providers/dns/baiducloud/baiducloud.go @@ -0,0 +1,171 @@ +// Package baiducloud implements a DNS provider for solving the DNS-01 challenge using Baidu Cloud. +package baiducloud + +import ( + "errors" + "fmt" + "time" + + baidudns "github.com/baidubce/bce-sdk-go/services/dns" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" +) + +// Environment variables names. +const ( + envNamespace = "BAIDUCLOUD_" + + EnvAccessKeyID = envNamespace + "ACCESS_KEY_ID" + EnvSecretAccessKey = envNamespace + "SECRET_ACCESS_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" +) + +// 300 is the minimum TTL for free users. +const defaultTTL = 300 + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + AccessKeyID string + SecretAccessKey string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *baidudns.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Baidu Cloud. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAccessKeyID, EnvSecretAccessKey) + if err != nil { + return nil, fmt.Errorf("baiducloud: %w", err) + } + + config := NewDefaultConfig() + config.AccessKeyID = values[EnvAccessKeyID] + config.SecretAccessKey = values[EnvSecretAccessKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Baidu Cloud. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("baiducloud: the configuration of the DNS provider is nil") + } + + if config.AccessKeyID == "" && config.SecretAccessKey == "" { + return nil, errors.New("baiducloud: credentials missing") + } + + client, err := baidudns.NewClient(config.AccessKeyID, config.SecretAccessKey, "") + if err != nil { + return nil, fmt.Errorf("baiducloud: %w", err) + } + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("baiducloud: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("baiducloud: %w", err) + } + + crr := &baidudns.CreateRecordRequest{ + Description: ptr.Pointer("lego"), + Rr: subDomain, + Type: "TXT", + Value: info.Value, + Ttl: ptr.Pointer(int32(d.config.TTL)), + } + + err = d.client.CreateRecord(dns01.UnFqdn(authZone), crr, "") + if err != nil { + return fmt.Errorf("baiducloud: create record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("baiducloud: could not find zone for domain %q: %w", domain, err) + } + + recordID, err := d.findRecordID(dns01.UnFqdn(authZone), info.Value) + if err != nil { + return fmt.Errorf("baiducloud: find record: %w", err) + } + + err = d.client.DeleteRecord(dns01.UnFqdn(authZone), recordID, "") + if err != nil { + return fmt.Errorf("baiducloud: delete record: %w", err) + } + + return nil +} + +func (d *DNSProvider) findRecordID(zoneName, tokenValue string) (string, error) { + lrr := &baidudns.ListRecordRequest{} + + for { + recordResponse, err := d.client.ListRecord(zoneName, lrr) + if err != nil { + return "", fmt.Errorf("baiducloud: list record: %w", err) + } + + for _, record := range recordResponse.Records { + if record.Type == "TXT" && record.Value == tokenValue { + return record.Id, nil + } + } + + if !recordResponse.IsTruncated { + break + } + + lrr.Marker = recordResponse.NextMarker + } + + return "", errors.New("record not found") +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/baiducloud/baiducloud.toml b/providers/dns/baiducloud/baiducloud.toml new file mode 100644 index 000000000..54f1f6312 --- /dev/null +++ b/providers/dns/baiducloud/baiducloud.toml @@ -0,0 +1,24 @@ +Name = "Baidu Cloud" +Description = '''''' +URL = "https://cloud.baidu.com" +Code = "baiducloud" +Since = "v4.23.0" + +Example = ''' +BAIDUCLOUD_ACCESS_KEY_ID="xxx" \ +BAIDUCLOUD_SECRET_ACCESS_KEY="yyy" \ +lego --dns baiducloud -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + BAIDUCLOUD_ACCESS_KEY_ID = "Access key" + BAIDUCLOUD_SECRET_ACCESS_KEY = "Secret access key" + [Configuration.Additional] + BAIDUCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + BAIDUCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + BAIDUCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + +[Links] + API = "https://cloud.baidu.com/doc/DNS/s/El4s7lssr" + GoClient = "https://github.com/baidubce/bce-sdk-go" diff --git a/providers/dns/baiducloud/baiducloud_test.go b/providers/dns/baiducloud/baiducloud_test.go new file mode 100644 index 000000000..483bfaf5e --- /dev/null +++ b/providers/dns/baiducloud/baiducloud_test.go @@ -0,0 +1,146 @@ +package baiducloud + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAccessKeyID, EnvSecretAccessKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAccessKeyID: "key", + EnvSecretAccessKey: "secret", + }, + }, + { + desc: "missing access key ID", + envVars: map[string]string{ + EnvAccessKeyID: "key", + }, + expected: "baiducloud: some credentials information are missing: BAIDUCLOUD_SECRET_ACCESS_KEY", + }, + { + desc: "missing secret access key", + envVars: map[string]string{ + EnvSecretAccessKey: "secret", + }, + expected: "baiducloud: some credentials information are missing: BAIDUCLOUD_ACCESS_KEY_ID", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "baiducloud: some credentials information are missing: BAIDUCLOUD_ACCESS_KEY_ID,BAIDUCLOUD_SECRET_ACCESS_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + accessKeyID string + secretAccessKey string + expected string + }{ + { + desc: "success", + accessKeyID: "key", + secretAccessKey: "secret", + }, + { + desc: "missing access key ID", + accessKeyID: "", + secretAccessKey: "secret", + expected: "baiducloud: accessKeyId should not be empty", + }, + { + desc: "missing secret access key", + accessKeyID: "key", + secretAccessKey: "", + expected: "baiducloud: secretKey should not be empty", + }, + { + desc: "missing credentials", + expected: "baiducloud: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.AccessKeyID = test.accessKeyID + config.SecretAccessKey = test.secretAccessKey + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/beget/beget.go b/providers/dns/beget/beget.go new file mode 100644 index 000000000..d4449deb8 --- /dev/null +++ b/providers/dns/beget/beget.go @@ -0,0 +1,164 @@ +// Package beget implements a DNS provider for solving the DNS-01 challenge using beget.com DNS. +package beget + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/beget/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "BEGET_" + + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "PASSWORD" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Username string + Password string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, 300), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 30*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for beget.com. +// Credentials must be passed in the environment variables: +// BEGET_USERNAME and BEGET_PASSWORD. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvPassword) + if err != nil { + return nil, fmt.Errorf("beget: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for beget.com. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("beget: the configuration of the DNS provider is nil") + } + + if config.Username == "" || config.Password == "" { + return nil, errors.New("beget: incomplete credentials, missing username and/or password") + } + + client := internal.NewClient(config.Username, config.Password) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{config: config, client: client}, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + records, err := d.client.GetTXTRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("beget: get TXT records: %w", err) + } + + records = append(records, internal.Record{ + Value: info.Value, + Data: "", // NOTE: there are 2 fields in the API for the same thing. + Priority: 10, + TTL: d.config.TTL, + }) + + err = d.client.ChangeTXTRecord(ctx, dns01.UnFqdn(info.EffectiveFQDN), records) + if err != nil { + return fmt.Errorf("beget: failed to create TXT records [domain: %s]: %w", + dns01.UnFqdn(info.EffectiveFQDN), err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + records, err := d.client.GetTXTRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("beget: get TXT records: %w", err) + } + + if len(records) == 0 { + return nil + } + + var updatedRecords []internal.Record + + for _, record := range records { + if record.Data == info.Value { + continue + } + + updatedRecords = append(updatedRecords, record) + } + + err = d.client.ChangeTXTRecord(ctx, dns01.UnFqdn(info.EffectiveFQDN), updatedRecords) + if err != nil { + return fmt.Errorf("beget: failed to remove TXT records [domain: %s]: %w", + dns01.UnFqdn(info.EffectiveFQDN), err) + } + + return nil +} diff --git a/providers/dns/beget/beget.toml b/providers/dns/beget/beget.toml new file mode 100644 index 000000000..4ed26d850 --- /dev/null +++ b/providers/dns/beget/beget.toml @@ -0,0 +1,24 @@ +Name = "Beget.com" +Description = '''''' +URL = "https://beget.com/" +Code = "beget" +Since = "v4.27.0" + +Example = ''' +BEGET_USERNAME=xxxxxx \ +BEGET_PASSWORD=yyyyyy \ +lego --dns beget -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + BEGET_USERNAME = "API username" + BEGET_PASSWORD = "API password" + [Configuration.Additional] + BEGET_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)" + BEGET_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" + BEGET_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + BEGET_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://beget.com/ru/kb/api/funkczii-upravleniya-dns" diff --git a/providers/dns/beget/beget_test.go b/providers/dns/beget/beget_test.go new file mode 100644 index 000000000..3cfb3c0b4 --- /dev/null +++ b/providers/dns/beget/beget_test.go @@ -0,0 +1,232 @@ +package beget + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvUsername: "123", + EnvPassword: "456", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvUsername: "", + EnvPassword: "", + }, + expected: "beget: some credentials information are missing: BEGET_USERNAME,BEGET_PASSWORD", + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvUsername: "", + EnvPassword: "456", + }, + expected: "beget: some credentials information are missing: BEGET_USERNAME", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvUsername: "123", + EnvPassword: "", + }, + expected: "beget: some credentials information are missing: BEGET_PASSWORD", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + username string + password string + expected string + }{ + { + desc: "success", + username: "123", + password: "456", + }, + { + desc: "missing credentials", + username: "", + password: "", + expected: "beget: incomplete credentials, missing username and/or password", + }, + { + desc: "missing username", + username: "", + password: "456", + expected: "beget: incomplete credentials, missing username and/or password", + }, + { + desc: "missing password", + username: "123", + password: "", + expected: "beget: incomplete credentials, missing username and/or password", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Username = test.username + config.Password = test.password + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + assert.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + assert.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + assert.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + assert.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Username = "user" + config.Password = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckQueryParameter(). + With("login", "user"). + With("passwd", "secret"). + With("input_format", "json"). + With("output_format", "json"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("GET /dns/getData", + servermock.ResponseFromInternal("getData-real.json"), + servermock.CheckQueryParameter(). + With("input_data", `{"fqdn":"_acme-challenge.example.com"}`), + ). + Route("GET /dns/changeRecords", + servermock.ResponseFromInternal("changeRecords-doc.json"), + servermock.CheckQueryParameter(). + With("input_data", `{"fqdn":"_acme-challenge.example.com","records":{"TXT":[{"txtdata":"v=spf1 redirect=beget.com","ttl":300},{"value":"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY","priority":10,"ttl":300}]}}`), + ). + Build(t) + + err := provider.Present("example.com", "", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("GET /dns/getData", + servermock.ResponseFromInternal("getData.json"), + servermock.CheckQueryParameter(). + With("input_data", `{"fqdn":"_acme-challenge.example.com"}`), + ). + Route("GET /dns/changeRecords", + servermock.ResponseFromInternal("changeRecords-doc.json"), + servermock.CheckQueryParameter(). + With("input_data", `{"fqdn":"_acme-challenge.example.com","records":{"TXT":[{"txtdata":"foo","ttl":300}]}}`), + ). + Build(t) + + err := provider.CleanUp("example.com", "", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp_empty(t *testing.T) { + provider := mockBuilder(). + Route("GET /dns/getData", + servermock.ResponseFromInternal("getData_empty.json"), + servermock.CheckQueryParameter(). + With("input_data", `{"fqdn":"_acme-challenge.example.com"}`), + ). + Route("/", + servermock.Noop().WithStatusCode(http.StatusInternalServerError)). + Build(t) + + err := provider.CleanUp("example.com", "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/beget/internal/client.go b/providers/dns/beget/internal/client.go new file mode 100644 index 000000000..9b9746ba2 --- /dev/null +++ b/providers/dns/beget/internal/client.go @@ -0,0 +1,137 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const defaultBaseURL = "https://api.beget.com/api/" + +// Client the beget.com client. +type Client struct { + login string + password string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient Creates a beget.com client. +func NewClient(login, password string) *Client { + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + login: login, + password: password, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + } +} + +// GetTXTRecords returns TXT records. +// https://beget.com/ru/kb/api/funkczii-upravleniya-dns#getdata +func (c *Client) GetTXTRecords(ctx context.Context, domain string) ([]Record, error) { + request := GetRecordsRequest{Fqdn: domain} + + resp, err := c.doRequest(ctx, request, "dns", "getData") + if err != nil { + return nil, err + } + + err = resp.HasError() + if err != nil { + return nil, err + } + + result := GetRecordsResult{} + + err = json.Unmarshal(resp.Answer.Result, &result) + if err != nil { + return nil, fmt.Errorf("unmarshal result: %s: %w", string(resp.Answer.Result), err) + } + + return result.Records.TXT, nil +} + +// ChangeTXTRecord changes TXT records. +// https://beget.com/ru/kb/api/funkczii-upravleniya-dns#changerecords +func (c *Client) ChangeTXTRecord(ctx context.Context, domain string, records []Record) error { + request := ChangeRecordsRequest{ + Fqdn: domain, + Records: RecordList{TXT: records}, + } + + resp, err := c.doRequest(ctx, request, "dns", "changeRecords") + if err != nil { + return err + } + + return resp.HasError() +} + +func (c *Client) doRequest(ctx context.Context, data any, fragments ...string) (*APIResponse, error) { + endpoint := c.BaseURL.JoinPath(fragments...) + + inputData, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to mashall input data: %w", err) + } + + query := endpoint.Query() + query.Add("input_data", string(inputData)) + query.Add("login", c.login) + query.Add("passwd", c.password) + query.Add("input_format", "json") + query.Add("output_format", "json") + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return nil, parseError(req, resp) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + var apiResp APIResponse + + err = json.Unmarshal(raw, &apiResp) + if err != nil { + return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return &apiResp, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var apiResp APIResponse + + err := json.Unmarshal(raw, &apiResp) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return fmt.Errorf("[status code %d] %w", resp.StatusCode, apiResp) +} diff --git a/providers/dns/beget/internal/client_test.go b/providers/dns/beget/internal/client_test.go new file mode 100644 index 000000000..4c127abf1 --- /dev/null +++ b/providers/dns/beget/internal/client_test.go @@ -0,0 +1,103 @@ +package internal + +import ( + "context" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + + client.HTTPClient = server.Client() + client.BaseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckQueryParameter(). + With("login", "user"). + With("passwd", "secret"). + With("input_format", "json"). + With("output_format", "json"), + ) +} + +func TestClient_GetTXTRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/getData", + servermock.ResponseFromFixture("getData-real.json"), + servermock.CheckQueryParameter(). + With("input_data", `{"fqdn":"example.com"}`), + ). + Build(t) + + data, err := client.GetTXTRecords(context.Background(), "example.com") + require.NoError(t, err) + + expected := []Record{{Data: "v=spf1 redirect=beget.com", TTL: 300}} + + assert.Equal(t, expected, data) +} + +func TestClient_ChangeTXTRecord(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/changeRecords", + servermock.ResponseFromFixture("changeRecords-doc.json"), + servermock.CheckQueryParameter(). + With("input_data", `{"fqdn":"sub.example.com","records":{"TXT":[{"value":"txtTXTtxt","priority":10,"ttl":300}]}}`), + ). + Build(t) + + records := []Record{{Value: "txtTXTtxt", TTL: 300, Priority: 10}} + + err := client.ChangeTXTRecord(context.Background(), "sub.example.com", records) + require.NoError(t, err) +} + +func TestClient_ChangeTXTRecord_error(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/changeRecords", + servermock.ResponseFromFixture("error.json")). + Build(t) + + records := []Record{{Data: "txtTXTtxt", TTL: 300}} + + err := client.ChangeTXTRecord(context.Background(), "sub.example.com", records) + require.Error(t, err) + + require.EqualError(t, err, "API error: NO_SUCH_METHOD: No such method") +} + +func TestClient_ChangeTXTRecord_answer_error(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/changeRecords", + servermock.ResponseFromFixture("answer_error.json")). + Build(t) + + records := []Record{{Data: "txtTXTtxt", TTL: 300}} + + err := client.ChangeTXTRecord(context.Background(), "sub.example.com", records) + require.Error(t, err) + + require.EqualError(t, err, "API answer error: INVALID_DATA: Login length cannot be greater than 12 characters") +} + +func TestClient_ChangeTXTRecord_remove(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/changeRecords", + servermock.ResponseFromFixture("changeRecords-doc.json"), + servermock.CheckQueryParameter(). + With("input_data", `{"fqdn":"sub.example.com","records":{}}`), + ). + Build(t) + + err := client.ChangeTXTRecord(context.Background(), "sub.example.com", nil) + require.NoError(t, err) +} diff --git a/providers/dns/beget/internal/fixtures/answer_error.json b/providers/dns/beget/internal/fixtures/answer_error.json new file mode 100644 index 000000000..12f5fdda7 --- /dev/null +++ b/providers/dns/beget/internal/fixtures/answer_error.json @@ -0,0 +1,12 @@ +{ + "status": "success", + "answer": { + "status": "error", + "errors": [ + { + "error_code": "INVALID_DATA", + "error_text": "Login length cannot be greater than 12 characters" + } + ] + } +} diff --git a/providers/dns/beget/internal/fixtures/changeRecords-doc.json b/providers/dns/beget/internal/fixtures/changeRecords-doc.json new file mode 100644 index 000000000..4c182d4e6 --- /dev/null +++ b/providers/dns/beget/internal/fixtures/changeRecords-doc.json @@ -0,0 +1,31 @@ +{ + "status": "success", + "answer": { + "status": "success", + "result": { + "A": [ + { + "priority": 10, + "value": "127.0.0.1" + } + ], + "MX": [ + { + "priority": 10, + "value": "mx1.beget.ru" + }, + { + "priority": 20, + "value": "mx2.beget.ru" + } + ], + "TXT": [ + { + "priority": 10, + "value": "TXT record" + } + ] + } + } +} + diff --git a/providers/dns/beget/internal/fixtures/error.json b/providers/dns/beget/internal/fixtures/error.json new file mode 100644 index 000000000..1dd2a111e --- /dev/null +++ b/providers/dns/beget/internal/fixtures/error.json @@ -0,0 +1,5 @@ +{ + "status": "error", + "error_text": "No such method", + "error_code": "NO_SUCH_METHOD" +} diff --git a/providers/dns/beget/internal/fixtures/getData-doc.json b/providers/dns/beget/internal/fixtures/getData-doc.json new file mode 100644 index 000000000..bed5b7461 --- /dev/null +++ b/providers/dns/beget/internal/fixtures/getData-doc.json @@ -0,0 +1,58 @@ +{ + "status": "success", + "answer": { + "status": "success", + "result": { + "is_under_control": 1, + "is_beget_dns": 1, + "is_subdomain": 0, + "fqdn": "beget.ru", + "records": { + "DNS": [ + { + "value": "ns1.beget.ru", + "priority": 10 + }, + { + "value": "ns2.beget.ru", + "priority": 20 + } + ], + "DNS_IP": [ + { + "value": null, + "priority": 10 + }, + { + "value": null, + "priority": 20 + } + ], + "A": [ + { + "value": "91.106.201.65", + "priority": "0" + } + ], + "MX": [ + { + "value": "mx1.beget.ru", + "priority": "10" + }, + { + "value": "mx2.beget.ru", + "priority": "20" + } + ], + "TXT": [ + { + "value": "", + "priority": 0 + } + ] + }, + "set_type": 1 + } + } +} + diff --git a/providers/dns/beget/internal/fixtures/getData-real.json b/providers/dns/beget/internal/fixtures/getData-real.json new file mode 100644 index 000000000..700c756e8 --- /dev/null +++ b/providers/dns/beget/internal/fixtures/getData-real.json @@ -0,0 +1,67 @@ +{ + "status": "success", + "answer": { + "status": "success", + "result": { + "is_under_control": true, + "is_beget_dns": true, + "is_subdomain": false, + "fqdn": "example.com", + "records": { + "MX": [ + { + "ttl": 300, + "exchange": "mx2.beget.com.", + "preference": 20 + }, + { + "ttl": 300, + "exchange": "mx1.beget.com.", + "preference": 10 + } + ], + "TXT": [ + { + "ttl": 300, + "txtdata": "v=spf1 redirect=beget.com" + } + ], + "A": [ + { + "ttl": 300, + "address": "1.2.3.4" + } + ], + "DNS": [ + { + "value": "ns1.beget.pro" + }, + { + "value": "ns2.beget.pro" + }, + { + "value": "ns1.beget.com" + }, + { + "value": "ns2.beget.com" + } + ], + "DNS_IP": [ + { + "value": "" + }, + { + "value": "" + }, + { + "value": "" + }, + { + "value": "" + } + ] + }, + "set_type": 1 + } + } +} diff --git a/providers/dns/beget/internal/fixtures/getData.json b/providers/dns/beget/internal/fixtures/getData.json new file mode 100644 index 000000000..571b6ac31 --- /dev/null +++ b/providers/dns/beget/internal/fixtures/getData.json @@ -0,0 +1,67 @@ +{ + "status": "success", + "answer": { + "status": "success", + "result": { + "is_under_control": true, + "is_beget_dns": true, + "is_subdomain": false, + "fqdn": "_acme-challenge.example.com", + "records": { + "MX": [ + { + "ttl": 300, + "exchange": "mx2.beget.com.", + "preference": 20 + }, + { + "ttl": 300, + "exchange": "mx1.beget.com.", + "preference": 10 + } + ], + "TXT": [ + { + "ttl": 300, + "txtdata": "foo" + } + ], + "A": [ + { + "ttl": 300, + "address": "1.2.3.4" + } + ], + "DNS": [ + { + "value": "ns1.beget.pro" + }, + { + "value": "ns2.beget.pro" + }, + { + "value": "ns1.beget.com" + }, + { + "value": "ns2.beget.com" + } + ], + "DNS_IP": [ + { + "value": "" + }, + { + "value": "" + }, + { + "value": "" + }, + { + "value": "" + } + ] + }, + "set_type": 1 + } + } +} diff --git a/providers/dns/beget/internal/fixtures/getData_empty.json b/providers/dns/beget/internal/fixtures/getData_empty.json new file mode 100644 index 000000000..ea819eeca --- /dev/null +++ b/providers/dns/beget/internal/fixtures/getData_empty.json @@ -0,0 +1,13 @@ +{ + "status": "success", + "answer": { + "status": "success", + "result": { + "is_under_control": true, + "is_beget_dns": true, + "is_subdomain": false, + "fqdn": "_acme-challenge.example.com", + "set_type": 1 + } + } +} diff --git a/providers/dns/beget/internal/types.go b/providers/dns/beget/internal/types.go new file mode 100644 index 000000000..f453bf628 --- /dev/null +++ b/providers/dns/beget/internal/types.go @@ -0,0 +1,100 @@ +package internal + +import ( + "encoding/json" + "fmt" + "strings" +) + +const successResult = "success" + +// APIResponse is the representation of an API response. +type APIResponse struct { + Status string `json:"status"` + + Answer *Answer `json:"answer,omitempty"` + + ErrorCode string `json:"error_code,omitempty"` + ErrorText string `json:"error_text,omitempty"` +} + +func (a APIResponse) Error() string { + return fmt.Sprintf("API %s: %s: %s", a.Status, a.ErrorCode, a.ErrorText) +} + +// HasError returns an error is the response contains an error. +func (a APIResponse) HasError() error { + if a.Status != successResult { + return a + } + + if a.Answer == nil || a.Status != successResult || a.Answer.Status != successResult { + return a.Answer + } + + return nil +} + +// Answer is the representation of an API response answer. +type Answer struct { + Status string `json:"status,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + + Errors []AnswerError `json:"errors,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + ErrorText string `json:"error_text,omitempty"` +} + +type AnswerError struct { + ErrorCode string `json:"error_code,omitempty"` + ErrorText string `json:"error_text,omitempty"` +} + +func (a Answer) Error() string { + parts := []string{fmt.Sprintf("API answer %s", a.Status)} + + if a.ErrorCode != "" { + parts = append(parts, a.ErrorCode) + } + + if a.ErrorText != "" { + parts = append(parts, a.ErrorText) + } + + if len(a.Errors) > 0 { + for _, e := range a.Errors { + parts = append(parts, e.ErrorCode, e.ErrorText) + } + } + + return strings.Join(parts, ": ") +} + +// GetRecordsRequest data representation for data get request. +type GetRecordsRequest struct { + Fqdn string `json:"fqdn,omitempty"` +} + +// ChangeRecordsRequest data representation for data change request. +type ChangeRecordsRequest struct { + Fqdn string `json:"fqdn,omitempty"` + Records RecordList `json:"records"` +} + +// RecordList List of entries (in this case only described TXT). +type RecordList struct { + TXT []Record `json:"TXT,omitempty"` +} + +// Record data representation for TXT record. +type Record struct { + Value string `json:"value,omitempty"` + Data string `json:"txtdata,omitempty"` + Priority int `json:"priority,omitempty"` + TTL int `json:"ttl,omitempty"` +} + +type GetRecordsResult struct { + Fqdn string `json:"fqdn"` + Records RecordList `json:"records"` +} diff --git a/providers/dns/binarylane/binarylane.go b/providers/dns/binarylane/binarylane.go new file mode 100644 index 000000000..5bbb7a16a --- /dev/null +++ b/providers/dns/binarylane/binarylane.go @@ -0,0 +1,165 @@ +// Package binarylane implements a DNS provider for solving the DNS-01 challenge using Binary Lane. +package binarylane + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/binarylane/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "BINARYLANE_" + + EnvAPIToken = envNamespace + "API_TOKEN" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIToken string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, 3600), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + recordIDs map[string]int64 + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for Binary Lane. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIToken) + if err != nil { + return nil, fmt.Errorf("binarylane: %w", err) + } + + config := NewDefaultConfig() + config.APIToken = values[EnvAPIToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Binary Lane. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("binarylane: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.APIToken) + if err != nil { + return nil, fmt.Errorf("binarylane: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]int64), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("binarylane: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("binarylane: %w", err) + } + + record := internal.Record{ + Type: "TXT", + Name: subDomain, + Data: info.Value, + TTL: d.config.TTL, + } + + response, err := d.client.CreateRecord(context.Background(), dns01.UnFqdn(authZone), record) + if err != nil { + return fmt.Errorf("binarylane: create record: %w", err) + } + + d.recordIDsMu.Lock() + d.recordIDs[token] = response.ID + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("binarylane: could not find zone for domain %q: %w", domain, err) + } + + // get the record's unique ID from when we created it + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + + if !ok { + return fmt.Errorf("binarylane: unknown record ID for '%s'", info.EffectiveFQDN) + } + + err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID) + if err != nil { + return fmt.Errorf("binarylane: delete record: %w", err) + } + + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/binarylane/binarylane.toml b/providers/dns/binarylane/binarylane.toml new file mode 100644 index 000000000..8b382f3b2 --- /dev/null +++ b/providers/dns/binarylane/binarylane.toml @@ -0,0 +1,22 @@ +Name = "Binary Lane" +Description = '''''' +URL = "https://www.binarylane.com.au/" +Code = "binarylane" +Since = "v4.26.0" + +Example = ''' +BINARYLANE_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns binarylane -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + BINARYLANE_API_TOKEN = "API token" + [Configuration.Additional] + BINARYLANE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + BINARYLANE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + BINARYLANE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + BINARYLANE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://api.binarylane.com.au/reference/#tag/Domains" diff --git a/providers/dns/binarylane/binarylane_test.go b/providers/dns/binarylane/binarylane_test.go new file mode 100644 index 000000000..4f2cfd230 --- /dev/null +++ b/providers/dns/binarylane/binarylane_test.go @@ -0,0 +1,118 @@ +package binarylane + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIToken).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIToken: "secret", + }, + }, + { + desc: "missing API token", + envVars: map[string]string{ + EnvAPIToken: "", + }, + expected: "binarylane: some credentials information are missing: BINARYLANE_API_TOKEN", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiToken string + expected string + }{ + { + desc: "success", + apiToken: "secret", + }, + { + desc: "missing API token", + expected: "binarylane: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIToken = test.apiToken + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/binarylane/internal/client.go b/providers/dns/binarylane/internal/client.go new file mode 100644 index 000000000..3f10e9f8b --- /dev/null +++ b/providers/dns/binarylane/internal/client.go @@ -0,0 +1,148 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const defaultBaseURL = "https://api.binarylane.com.au/v2/" + +const authorizationHeader = "Authorization" + +// Client the Binary Lane API client. +type Client struct { + apiToken string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(apiToken string) (*Client, error) { + if apiToken == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + apiToken: apiToken, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// CreateRecord Creates a new domain record. +// https://api.binarylane.com.au/reference/#tag/Domains/paths/~1v2~1domains~1%7Bdomain_name%7D~1records/post +func (c *Client) CreateRecord(ctx context.Context, domain string, record Record) (*Record, error) { + endpoint := c.baseURL.JoinPath("domains", domain, "records") + + if record.Name == "" { + record.Name = "@" + } + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return nil, err + } + + var result APIResponse + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result.DomainRecord, nil +} + +// DeleteRecord Deletes an existing domain record. +// https://api.binarylane.com.au/reference/#tag/Domains/paths/~1v2~1domains~1%7Bdomain_name%7D~1records~1%7Brecord_id%7D/delete +func (c *Client) DeleteRecord(ctx context.Context, domainName string, recordID int64) error { + endpoint := c.baseURL.JoinPath("domains", domainName, "records", strconv.FormatInt(recordID, 10)) + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) do(req *http.Request, result any) error { + req.Header.Set(authorizationHeader, "Bearer "+c.apiToken) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/binarylane/internal/client_test.go b/providers/dns/binarylane/internal/client_test.go new file mode 100644 index 000000000..0398d5adf --- /dev/null +++ b/providers/dns/binarylane/internal/client_test.go @@ -0,0 +1,97 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } + + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer secret"), + ) +} + +func TestClient_CreateRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/example.com/records", + servermock.ResponseFromFixture("create_record.json"), + servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). + Build(t) + + record := Record{ + Type: "TXT", + Name: "foo", + Data: "txtTXTtxt", + TTL: 300, + } + + rec, err := client.CreateRecord(t.Context(), "example.com", record) + require.NoError(t, err) + + expected := &Record{ + ID: 123, + Type: "TXT", + Name: "foo", + Data: "txtTXTtxt", + TTL: 300, + } + + require.Equal(t, expected, rec) +} + +func TestClient_CreateRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/example.com/records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + record := Record{ + Type: "TXT", + Name: "foo", + Data: "txtTXTtxt", + TTL: 300, + } + + _, err := client.CreateRecord(t.Context(), "example.com", record) + require.EqualError(t, err, "400: type: title: detail: instance: property1: a") +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domains/example.com/records/123", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) + + err := client.DeleteRecord(t.Context(), "example.com", 123) + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domains/example.com/records/123", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + err := client.DeleteRecord(t.Context(), "example.com", 123) + require.EqualError(t, err, "400: type: title: detail: instance: property1: a") +} diff --git a/providers/dns/binarylane/internal/fixtures/create_record-request.json b/providers/dns/binarylane/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..98a349650 --- /dev/null +++ b/providers/dns/binarylane/internal/fixtures/create_record-request.json @@ -0,0 +1,6 @@ +{ + "type": "TXT", + "name": "foo", + "data": "txtTXTtxt", + "ttl": 300 +} diff --git a/providers/dns/binarylane/internal/fixtures/create_record.json b/providers/dns/binarylane/internal/fixtures/create_record.json new file mode 100644 index 000000000..709bef23e --- /dev/null +++ b/providers/dns/binarylane/internal/fixtures/create_record.json @@ -0,0 +1,9 @@ +{ + "domain_record": { + "id": 123, + "type": "TXT", + "name": "foo", + "data": "txtTXTtxt", + "ttl": 300 + } +} diff --git a/providers/dns/binarylane/internal/fixtures/error.json b/providers/dns/binarylane/internal/fixtures/error.json new file mode 100644 index 000000000..79d115f74 --- /dev/null +++ b/providers/dns/binarylane/internal/fixtures/error.json @@ -0,0 +1,14 @@ +{ + "type": "type", + "title": "title", + "status": 400, + "detail": "detail", + "instance": "instance", + "errors": { + "property1": [ + "a" + ] + }, + "property1": null, + "property2": null +} diff --git a/providers/dns/binarylane/internal/types.go b/providers/dns/binarylane/internal/types.go new file mode 100644 index 000000000..06d4be5c0 --- /dev/null +++ b/providers/dns/binarylane/internal/types.go @@ -0,0 +1,44 @@ +package internal + +import ( + "fmt" + "strings" +) + +type APIError struct { + Type string `json:"type"` + Title string `json:"title"` + Status int `json:"status"` + Detail string `json:"detail"` + Instance string `json:"instance"` + Errors map[string][]string `json:"errors"` +} + +func (a *APIError) Error() string { + msg := new(strings.Builder) + + _, _ = fmt.Fprintf(msg, "%d: %s: %s: %s: %s", a.Status, a.Type, a.Title, a.Detail, a.Instance) + + for s, values := range a.Errors { + _, _ = fmt.Fprintf(msg, ": %s: %s", s, strings.Join(values, ", ")) + } + + return msg.String() +} + +type Record struct { + ID int64 `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Data string `json:"data,omitempty"` + Priority int `json:"priority,omitempty"` + Port int `json:"port,omitempty"` + TTL int `json:"ttl,omitempty"` + Weight int `json:"weight,omitempty"` + Flags int `json:"flags,omitempty"` + Tag string `json:"tag,omitempty"` +} + +type APIResponse struct { + DomainRecord *Record `json:"domain_record"` +} diff --git a/providers/dns/bindman/bindman.go b/providers/dns/bindman/bindman.go index 1ec396075..c529cb63c 100644 --- a/providers/dns/bindman/bindman.go +++ b/providers/dns/bindman/bindman.go @@ -7,9 +7,11 @@ import ( "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/labbsr0x/bindman-dns-webhook/src/client" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + bindman "github.com/labbsr0x/bindman-dns-webhook/src/client" ) // Environment variables names. @@ -23,6 +25,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { PropagationTimeout time.Duration @@ -45,7 +49,7 @@ func NewDefaultConfig() *Config { // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config - client *client.DNSWebhookClient + client *bindman.DNSWebhookClient } // NewDNSProvider returns a DNSProvider instance configured for Bindman. @@ -72,12 +76,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("bindman: bindman manager address missing") } - bClient, err := client.New(config.BaseURL, config.HTTPClient) + // Because the client.New uses the http.DefaultClient. + if config.HTTPClient == nil { + config.HTTPClient = &http.Client{Timeout: time.Minute} + } + + client, err := bindman.New(config.BaseURL, clientdebug.Wrap(config.HTTPClient)) if err != nil { return nil, fmt.Errorf("bindman: %w", err) } - return &DNSProvider{config: config, client: bClient}, nil + return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record using the specified parameters. @@ -89,6 +98,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { if err := d.client.AddRecord(info.EffectiveFQDN, "TXT", info.Value); err != nil { return fmt.Errorf("bindman: %w", err) } + return nil } @@ -99,6 +109,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { if err := d.client.RemoveRecord(info.EffectiveFQDN, "TXT"); err != nil { return fmt.Errorf("bindman: %w", err) } + return nil } diff --git a/providers/dns/bindman/bindman.toml b/providers/dns/bindman/bindman.toml index 4befe9e9d..768601588 100644 --- a/providers/dns/bindman/bindman.toml +++ b/providers/dns/bindman/bindman.toml @@ -6,16 +6,16 @@ Since = "v2.6.0" Example = ''' BINDMAN_MANAGER_ADDRESS= \ -lego --email you@example.com --dns bindman -d '*.example.com' -d example.com run +lego --dns bindman -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] BINDMAN_MANAGER_ADDRESS = "The server URL, should have scheme, hostname, and port (if required) of the Bindman-DNS Manager server" [Configuration.Additional] - BINDMAN_POLLING_INTERVAL = "Time between DNS propagation check" - BINDMAN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - BINDMAN_HTTP_TIMEOUT = "API request timeout" + BINDMAN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + BINDMAN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + BINDMAN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" [Links] API = "https://gitlab.isc.org/isc-projects/bind9" diff --git a/providers/dns/bindman/bindman_test.go b/providers/dns/bindman/bindman_test.go index a0db025e7..978a1d006 100644 --- a/providers/dns/bindman/bindman_test.go +++ b/providers/dns/bindman/bindman_test.go @@ -1,14 +1,13 @@ -// Package bindman implements a DNS provider for solving the DNS-01 challenge. package bindman import ( - "errors" "net/http" + "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" - bindmanClient "github.com/labbsr0x/bindman-dns-webhook/src/client" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) @@ -47,6 +46,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -106,10 +106,24 @@ func TestNewDNSProviderConfig(t *testing.T) { } } +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.BaseURL = server.URL + config.HTTPClient = server.Client() + + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With("User-Agent", "bindman-dns-webhook-client")) +} + func TestDNSProvider_Present(t *testing.T) { testCases := []struct { name string - client *bindmanClient.DNSWebhookClient + mock *servermock.Builder[*DNSProvider] domain string token string keyAuth string @@ -117,28 +131,31 @@ func TestDNSProvider_Present(t *testing.T) { }{ { name: "success when add record function return no error", - client: &bindmanClient.DNSWebhookClient{ - ClientAPI: &MockHTTPClientAPI{Status: http.StatusNoContent}, - }, - domain: "hello.test.com", + mock: mockBuilder(). + Route("POST /records", + servermock.Noop().WithStatusCode(http.StatusNoContent), + servermock.CheckRequestJSONBodyFromFixture("add_record-request.json"), + ), + domain: "example.com", keyAuth: "szDTG4zmM0GsKG91QAGO2M4UYOJMwU8oFpWOP7eTjCw", expectError: false, }, { name: "error when add record function return an error", - client: &bindmanClient.DNSWebhookClient{ - ClientAPI: &MockHTTPClientAPI{Error: errors.New("error adding record")}, - }, - domain: "hello.test.com", + mock: mockBuilder(). + Route("POST /records", + servermock.ResponseFromFixture("error.json"), + ), + domain: "example.com", keyAuth: "szDTG4zmM0GsKG91QAGO2M4UYOJMwU8oFpWOP7eTjCw", expectError: true, }, } for _, test := range testCases { t.Run(test.name, func(t *testing.T) { - d := &DNSProvider{client: test.client} + provider := test.mock.Build(t) - err := d.Present(test.domain, test.token, test.keyAuth) + err := provider.Present(test.domain, test.token, test.keyAuth) if test.expectError { require.Error(t, err) } else { @@ -151,7 +168,7 @@ func TestDNSProvider_Present(t *testing.T) { func TestDNSProvider_CleanUp(t *testing.T) { testCases := []struct { name string - client *bindmanClient.DNSWebhookClient + mock *servermock.Builder[*DNSProvider] domain string token string keyAuth string @@ -159,30 +176,33 @@ func TestDNSProvider_CleanUp(t *testing.T) { }{ { name: "success when remove record function return no error", - client: &bindmanClient.DNSWebhookClient{ - ClientAPI: &MockHTTPClientAPI{Status: http.StatusNoContent}, - }, - domain: "hello.test.com", + mock: mockBuilder(). + Route("DELETE /records/_acme-challenge.example.com./TXT", + servermock.Noop().WithStatusCode(http.StatusNoContent), + ), + domain: "example.com", keyAuth: "szDTG4zmM0GsKG91QAGO2M4UYOJMwU8oFpWOP7eTjCw", expectError: false, }, { name: "error when remove record function return an error", - client: &bindmanClient.DNSWebhookClient{ - ClientAPI: &MockHTTPClientAPI{Error: errors.New("error adding record")}, - }, - domain: "hello.test.com", + mock: mockBuilder(). + Route("DELETE /records/_acme-challenge.example.com./TXT", + servermock.ResponseFromFixture("error.json"), + ), + domain: "example.com", keyAuth: "szDTG4zmM0GsKG91QAGO2M4UYOJMwU8oFpWOP7eTjCw", expectError: true, }, } + for _, test := range testCases { t.Run(test.name, func(t *testing.T) { - d := &DNSProvider{client: test.client} + provider := test.mock.Build(t) - err := d.CleanUp(test.domain, test.token, test.keyAuth) + err := provider.CleanUp(test.domain, test.token, test.keyAuth) if test.expectError { - require.Error(t, err) + require.ErrorContains(t, err, "bindman: ERROR (400): bar; ") } else { require.NoError(t, err) } @@ -196,6 +216,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -209,6 +230,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -217,25 +239,3 @@ func TestLiveCleanUp(t *testing.T) { err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } - -type MockHTTPClientAPI struct { - Data []byte - Status int - Error error -} - -func (m *MockHTTPClientAPI) Put(url string, data []byte) (*http.Response, []byte, error) { - return &http.Response{StatusCode: m.Status}, m.Data, m.Error -} - -func (m *MockHTTPClientAPI) Post(url string, data []byte) (*http.Response, []byte, error) { - return &http.Response{StatusCode: m.Status}, m.Data, m.Error -} - -func (m *MockHTTPClientAPI) Get(url string) (*http.Response, []byte, error) { - return &http.Response{StatusCode: m.Status}, m.Data, m.Error -} - -func (m *MockHTTPClientAPI) Delete(url string) (*http.Response, []byte, error) { - return &http.Response{StatusCode: m.Status}, m.Data, m.Error -} diff --git a/providers/dns/bindman/fixtures/add_record-request.json b/providers/dns/bindman/fixtures/add_record-request.json new file mode 100644 index 000000000..9585565b8 --- /dev/null +++ b/providers/dns/bindman/fixtures/add_record-request.json @@ -0,0 +1,5 @@ +{ + "name": "_acme-challenge.example.com.", + "value": "_EYMkjukXEMcXbnvpT6WLESzfYhxH190NKTBo3cpu-E", + "type": "TXT" +} diff --git a/providers/dns/bindman/fixtures/error.json b/providers/dns/bindman/fixtures/error.json new file mode 100644 index 000000000..c8a014510 --- /dev/null +++ b/providers/dns/bindman/fixtures/error.json @@ -0,0 +1,5 @@ +{ + "message": "bar", + "code": 400, + "details": ["foo"] +} diff --git a/providers/dns/bluecat/bluecat.go b/providers/dns/bluecat/bluecat.go index ad7add9fd..b26fab8be 100644 --- a/providers/dns/bluecat/bluecat.go +++ b/providers/dns/bluecat/bluecat.go @@ -8,10 +8,12 @@ import ( "net/http" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/bluecat/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -32,6 +34,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string @@ -107,6 +111,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } diff --git a/providers/dns/bluecat/bluecat.toml b/providers/dns/bluecat/bluecat.toml index e7eb45664..15df6ed34 100644 --- a/providers/dns/bluecat/bluecat.toml +++ b/providers/dns/bluecat/bluecat.toml @@ -11,7 +11,7 @@ BLUECAT_USER_NAME=myusername \ BLUECAT_CONFIG_NAME=myconfig \ BLUECAT_SERVER_URL=https://bam.example.com \ BLUECAT_TTL=30 \ -lego --email you@example.com --dns bluecat -d '*.example.com' -d example.com run +lego --dns bluecat -d '*.example.com' -d example.com run ''' [Configuration] @@ -22,10 +22,10 @@ lego --email you@example.com --dns bluecat -d '*.example.com' -d example.com run BLUECAT_CONFIG_NAME = "Configuration name" BLUECAT_DNS_VIEW = "External DNS View Name" [Configuration.Additional] - BLUECAT_POLLING_INTERVAL = "Time between DNS propagation check" - BLUECAT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - BLUECAT_TTL = "The TTL of the TXT record used for the DNS challenge" - BLUECAT_HTTP_TIMEOUT = "API request timeout" + BLUECAT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + BLUECAT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + BLUECAT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + BLUECAT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" BLUECAT_SKIP_DEPLOY = "Skip deployements" [Links] diff --git a/providers/dns/bluecat/bluecat_test.go b/providers/dns/bluecat/bluecat_test.go index 5a3670e3a..38b110470 100644 --- a/providers/dns/bluecat/bluecat_test.go +++ b/providers/dns/bluecat/bluecat_test.go @@ -105,6 +105,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -219,6 +220,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -232,6 +234,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/bluecat/internal/client.go b/providers/dns/bluecat/internal/client.go index a2649a455..d517ea857 100644 --- a/providers/dns/bluecat/internal/client.go +++ b/providers/dns/bluecat/internal/client.go @@ -36,7 +36,7 @@ type Client struct { HTTPClient *http.Client } -func NewClient(baseURL string, username, password string) *Client { +func NewClient(baseURL, username, password string) *Client { bu, _ := url.Parse(baseURL) return &Client{ @@ -106,6 +106,7 @@ func (c *Client) AddEntity(ctx context.Context, parentID uint, entity Entity) (u // addEntity responds only with body text containing the ID of the created record addTxtResp := string(raw) + id, err := strconv.ParseUint(addTxtResp, 10, 64) if err != nil { return 0, fmt.Errorf("addEntity request failed: %s", addTxtResp) @@ -147,6 +148,7 @@ func (c *Client) GetEntityByName(ctx context.Context, parentID uint, name, objTy } var entity EntityResponse + err = json.Unmarshal(raw, &entity) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) diff --git a/providers/dns/bluecat/internal/client_test.go b/providers/dns/bluecat/internal/client_test.go index 206d7d1a4..d4776b8a1 100644 --- a/providers/dns/bluecat/internal/client_test.go +++ b/providers/dns/bluecat/internal/client_test.go @@ -1,41 +1,45 @@ package internal import ( - "context" "encoding/json" "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestClient_LookupParentZoneID(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient(server.URL, "user", "secret") client.HTTPClient = server.Client() - mux.HandleFunc("/Services/REST/v1/getEntityByName", func(rw http.ResponseWriter, req *http.Request) { - query := req.URL.Query() + return client, nil +} - if query.Get("name") == "com" { - _ = json.NewEncoder(rw).Encode(EntityResponse{ - ID: 2, - Name: "com", - Type: ZoneType, - Properties: "test", - }) - return - } +func TestClient_LookupParentZoneID(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /Services/REST/v1/getEntityByName", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + query := req.URL.Query() - http.Error(rw, "{}", http.StatusOK) - }) + if query.Get("name") == "com" { + _ = json.NewEncoder(rw).Encode(EntityResponse{ + ID: 2, + Name: "com", + Type: ZoneType, + Properties: "test", + }) - parentID, name, err := client.LookupParentZoneID(context.Background(), 2, "foo.example.com") + return + } + + _, _ = rw.Write([]byte(`{}`)) + })). + Build(t) + + parentID, name, err := client.LookupParentZoneID(t.Context(), 2, "foo.example.com") require.NoError(t, err) assert.EqualValues(t, 2, parentID) diff --git a/providers/dns/bluecat/internal/identity_test.go b/providers/dns/bluecat/internal/identity_test.go index 378f6ab38..9ad4c18e6 100644 --- a/providers/dns/bluecat/internal/identity_test.go +++ b/providers/dns/bluecat/internal/identity_test.go @@ -1,12 +1,9 @@ package internal import ( - "context" - "fmt" - "net/http" - "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -14,41 +11,18 @@ import ( const fakeToken = "BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM=" func TestClient_CreateAuthenticatedContext(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /Services/REST/v1/login", + servermock.RawStringResponse(fakeToken), + servermock.CheckQueryParameter(). + With("username", "user"). + With("password", "secret")). + Route("DELETE /Services/REST/v1/delete", nil, + servermock.CheckHeader(). + WithAuthorization(fakeToken)). + Build(t) - client := NewClient(server.URL, "user", "secret") - client.HTTPClient = server.Client() - - mux.HandleFunc("/Services/REST/v1/login", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - query := req.URL.Query() - if query.Get("username") != "user" { - http.Error(rw, fmt.Sprintf("invalid username %s", query.Get("username")), http.StatusUnauthorized) - return - } - - if query.Get("password") != "secret" { - http.Error(rw, fmt.Sprintf("invalid password %s", query.Get("password")), http.StatusUnauthorized) - return - } - - _, _ = fmt.Fprint(rw, fakeToken) - }) - mux.HandleFunc("/Services/REST/v1/delete", func(rw http.ResponseWriter, req *http.Request) { - authorization := req.Header.Get(authorizationHeader) - if authorization != fakeToken { - http.Error(rw, fmt.Sprintf("invalid credential: %s", authorization), http.StatusUnauthorized) - return - } - }) - - ctx, err := client.CreateAuthenticatedContext(context.Background()) + ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) at := getToken(ctx) diff --git a/providers/dns/bluecatv2/bluecatv2.go b/providers/dns/bluecatv2/bluecatv2.go new file mode 100644 index 000000000..0efe99661 --- /dev/null +++ b/providers/dns/bluecatv2/bluecatv2.go @@ -0,0 +1,249 @@ +// Package bluecatv2 implements a DNS provider for solving the DNS-01 challenge using Bluecat v2. +package bluecatv2 + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/bluecatv2/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "BLUECATV2_" + + EnvServerURL = envNamespace + "SERVER_URL" + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "PASSWORD" + EnvConfigName = envNamespace + "CONFIG_NAME" + EnvViewName = envNamespace + "VIEW_NAME" + EnvSkipDeploy = envNamespace + "SKIP_DEPLOY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + ServerURL string + Username string + Password string + ConfigName string + ViewName string + SkipDeploy bool + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + SkipDeploy: env.GetOrDefaultBool(EnvSkipDeploy, false), + + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + zoneIDs map[string]int64 + recordIDs map[string]int64 + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for Bluecat v2. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvServerURL, EnvUsername, EnvPassword, EnvConfigName, EnvViewName) + if err != nil { + return nil, fmt.Errorf("bluecatv2: %w", err) + } + + config := NewDefaultConfig() + config.ServerURL = values[EnvServerURL] + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + config.ConfigName = values[EnvConfigName] + config.ViewName = values[EnvViewName] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Bluecat v2. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("bluecatv2: the configuration of the DNS provider is nil") + } + + if config.ServerURL == "" { + return nil, errors.New("bluecatv2: missing server URL") + } + + if config.ConfigName == "" { + return nil, errors.New("bluecatv2: missing configuration name") + } + + if config.ViewName == "" { + return nil, errors.New("bluecatv2: missing view name") + } + + client, err := internal.NewClient(config.ServerURL, config.Username, config.Password) + if err != nil { + return nil, fmt.Errorf("bluecatv2: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]int64), + zoneIDs: make(map[string]int64), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx, err := d.client.CreateAuthenticatedContext(context.Background()) + if err != nil { + return fmt.Errorf("bluecatv2: %w", err) + } + + zone, err := d.findZone(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("bluecatv2: %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.AbsoluteName) + if err != nil { + return fmt.Errorf("bluecatv2: %w", err) + } + + record := internal.RecordTXT{ + CommonResource: internal.CommonResource{ + Type: "TXTRecord", + Name: subDomain, + }, + Text: info.Value, + TTL: d.config.TTL, + RecordType: "TXT", + } + + newRecord, err := d.client.CreateZoneResourceRecord(ctx, zone.ID, record) + if err != nil { + return fmt.Errorf("bluecatv2: create resource record: %w", err) + } + + d.recordIDsMu.Lock() + d.zoneIDs[token] = zone.ID + d.recordIDs[token] = newRecord.ID + d.recordIDsMu.Unlock() + + if d.config.SkipDeploy { + return nil + } + + _, err = d.client.CreateZoneDeployment(ctx, zone.ID) + if err != nil { + return fmt.Errorf("bluecat: deploy zone: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + d.recordIDsMu.Lock() + recordID, recordOK := d.recordIDs[token] + zoneID, zoneOK := d.zoneIDs[token] + d.recordIDsMu.Unlock() + + if !recordOK { + return fmt.Errorf("bluecatv2: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + if !zoneOK { + return fmt.Errorf("bluecatv2: unknown zone ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + ctx, err := d.client.CreateAuthenticatedContext(context.Background()) + if err != nil { + return fmt.Errorf("bluecatv2: %w", err) + } + + err = d.client.DeleteResourceRecord(ctx, recordID) + if err != nil { + return fmt.Errorf("bluecatv2: delete resource record: %w", err) + } + + if d.config.SkipDeploy { + return nil + } + + _, err = d.client.CreateZoneDeployment(ctx, zoneID) + if err != nil { + return fmt.Errorf("bluecat: deploy zone: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*internal.ZoneResource, error) { + for name := range dns01.UnFqdnDomainsSeq(fqdn) { + opts := &internal.CollectionOptions{ + Fields: "id,absoluteName,configuration.id,configuration.name,view.id,view.name", + Filter: internal.And( + internal.Eq("absoluteName", name), + internal.Eq("configuration.name", d.config.ConfigName), + internal.Eq("view.name", d.config.ViewName), + ).String(), + } + + zones, err := d.client.RetrieveZones(ctx, opts) + if err != nil { + // TODO(ldez) maybe add a log in v5. + continue + } + + for _, zone := range zones { + if zone.AbsoluteName == name { + return &zone, nil + } + } + } + + return nil, fmt.Errorf("no zone found for fqdn: %s", fqdn) +} diff --git a/providers/dns/bluecatv2/bluecatv2.toml b/providers/dns/bluecatv2/bluecatv2.toml new file mode 100644 index 000000000..6ec3781c6 --- /dev/null +++ b/providers/dns/bluecatv2/bluecatv2.toml @@ -0,0 +1,33 @@ +Name = "Bluecat v2" +Description = '''''' +URL = "https://www.bluecatnetworks.com" +Code = "bluecatv2" +Since = "v4.32.0" + +Example = ''' +BLUECATV2_SERVER_URL="https://example.com" \ +BLUECATV2_USERNAME="xxx" \ +BLUECATV2_PASSWORD="yyy" \ +BLUECATV2_CONFIG_NAME="myConfiguration" \ +BLUECATV2_VIEW_NAME="myView" \ +lego --dns bluecatv2 -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + BLUECAT_SERVER_URL = "The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve" + BLUECATV2_USERNAME = "API username" + BLUECATV2_PASSWORD = "API password" + BLUECATV2_CONFIG_NAME = "Configuration name" + BLUECATV2_VIEW_NAME = "DNS View Name" + [Configuration.Additional] + BLUECATV2_SKIP_DEPLOY = "Skip quick deployements" + BLUECATV2_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + BLUECATV2_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + BLUECATV2_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + BLUECATV2_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Introduction/9.6.0" + Swagger = "http://{Address_Manager_IP}/api/openapi.json" + SwaggerDump = "https://github.com/go-acme/lego/discussions/2218#discussioncomment-13060545" diff --git a/providers/dns/bluecatv2/bluecatv2_test.go b/providers/dns/bluecatv2/bluecatv2_test.go new file mode 100644 index 000000000..d852f0e18 --- /dev/null +++ b/providers/dns/bluecatv2/bluecatv2_test.go @@ -0,0 +1,414 @@ +package bluecatv2 + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/go-acme/lego/v4/providers/dns/bluecatv2/internal" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvServerURL, + EnvUsername, + EnvPassword, + EnvConfigName, + EnvViewName, + EnvSkipDeploy, +).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvServerURL: "https://example.com/", + EnvUsername: "userA", + EnvPassword: "secret", + EnvConfigName: "myConfig", + EnvViewName: "myView", + }, + }, + { + desc: "missing server URL", + envVars: map[string]string{ + EnvServerURL: "", + EnvUsername: "userA", + EnvPassword: "secret", + EnvConfigName: "myConfig", + EnvViewName: "myView", + }, + expected: "bluecatv2: some credentials information are missing: BLUECATV2_SERVER_URL", + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvServerURL: "https://example.com/", + EnvUsername: "", + EnvPassword: "secret", + EnvConfigName: "myConfig", + EnvViewName: "myView", + }, + expected: "bluecatv2: some credentials information are missing: BLUECATV2_USERNAME", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvServerURL: "https://example.com/", + EnvUsername: "userA", + EnvPassword: "", + EnvConfigName: "myConfig", + EnvViewName: "myView", + }, + expected: "bluecatv2: some credentials information are missing: BLUECATV2_PASSWORD", + }, + { + desc: "missing configuration name", + envVars: map[string]string{ + EnvServerURL: "https://example.com/", + EnvUsername: "userA", + EnvPassword: "secret", + EnvConfigName: "", + EnvViewName: "myView", + }, + expected: "bluecatv2: some credentials information are missing: BLUECATV2_CONFIG_NAME", + }, + { + desc: "missing view name", + envVars: map[string]string{ + EnvServerURL: "https://example.com/", + EnvUsername: "userA", + EnvPassword: "secret", + EnvConfigName: "myConfig", + EnvViewName: "", + }, + expected: "bluecatv2: some credentials information are missing: BLUECATV2_VIEW_NAME", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "bluecatv2: some credentials information are missing: BLUECATV2_SERVER_URL,BLUECATV2_USERNAME,BLUECATV2_PASSWORD,BLUECATV2_CONFIG_NAME,BLUECATV2_VIEW_NAME", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + serverURL string + username string + password string + configName string + viewName string + expected string + }{ + { + desc: "success", + serverURL: "https://example.com/", + username: "userA", + password: "secret", + configName: "myConfig", + viewName: "myView", + }, + { + desc: "missing server URL", + username: "userA", + password: "secret", + configName: "myConfig", + viewName: "myView", + expected: "bluecatv2: missing server URL", + }, + { + desc: "missing username", + serverURL: "https://example.com/", + password: "secret", + configName: "myConfig", + viewName: "myView", + expected: "bluecatv2: credentials missing", + }, + { + desc: "missing password", + serverURL: "https://example.com/", + username: "userA", + configName: "myConfig", + viewName: "myView", + expected: "bluecatv2: credentials missing", + }, + { + desc: "missing configuration name", + serverURL: "https://example.com/", + username: "userA", + password: "secret", + viewName: "myView", + expected: "bluecatv2: missing configuration name", + }, + { + desc: "missing view name", + serverURL: "https://example.com/", + username: "userA", + password: "secret", + configName: "myConfig", + expected: "bluecatv2: missing view name", + }, + { + desc: "missing credentials", + expected: "bluecatv2: missing server URL", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.ServerURL = test.serverURL + config.Username = test.username + config.Password = test.password + config.ConfigName = test.configName + config.ViewName = test.viewName + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + + config.ServerURL = server.URL + config.Username = "userA" + config.Password = "secret" + config.ConfigName = "myConfiguration" + config.ViewName = "myView" + + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("POST /api/v2/sessions", + servermock.ResponseFromInternal("postSession.json"), + servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"), + ). + Route("GET /api/v2/configurations", + servermock.ResponseFromInternal("configurations.json"), + servermock.CheckQueryParameter().Strict(). + With("filter", "name:eq('myConfiguration')"), + ). + Route("GET /api/v2/configurations/12345/views", + servermock.ResponseFromInternal("views.json"), + servermock.CheckQueryParameter().Strict(). + With("filter", "name:eq('myView')"), + ). + Route("GET /api/v2/zones", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + filter := req.URL.Query().Get("filter") + + if strings.Contains(filter, internal.Eq("absoluteName", "example.com").String()) { + servermock.ResponseFromInternal("zones.json").ServeHTTP(rw, req) + + return + } + + servermock.ResponseFromInternal("error.json"). + WithStatusCode(http.StatusNotFound).ServeHTTP(rw, req) + }), + ). + Route("POST /api/v2/zones/12345/resourceRecords", + servermock.ResponseFromInternal("postZoneResourceRecord.json"), + servermock.CheckRequestJSONBodyFromInternal("postZoneResourceRecord-request.json"), + ). + Route("POST /api/v2/zones/12345/deployments", + servermock.ResponseFromInternal("postZoneDeployment.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromInternal("postZoneDeployment-request.json"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_Present_skipDeploy(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(map[string]string{ + EnvSkipDeploy: "true", + }) + + provider := mockBuilder(). + Route("POST /api/v2/sessions", + servermock.ResponseFromInternal("postSession.json"), + servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"), + ). + Route("GET /api/v2/configurations", + servermock.ResponseFromInternal("configurations.json"), + servermock.CheckQueryParameter().Strict(). + With("filter", "name:eq('myConfiguration')"), + ). + Route("GET /api/v2/configurations/12345/views", + servermock.ResponseFromInternal("views.json"), + servermock.CheckQueryParameter().Strict(). + With("filter", "name:eq('myView')"), + ). + Route("GET /api/v2/zones", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + filter := req.URL.Query().Get("filter") + + if strings.Contains(filter, internal.Eq("absoluteName", "example.com").String()) { + servermock.ResponseFromInternal("zones.json").ServeHTTP(rw, req) + + return + } + + servermock.ResponseFromInternal("error.json"). + WithStatusCode(http.StatusNotFound).ServeHTTP(rw, req) + }), + ). + Route("POST /api/v2/zones/12345/resourceRecords", + servermock.ResponseFromInternal("postZoneResourceRecord.json"), + servermock.CheckRequestJSONBodyFromInternal("postZoneResourceRecord-request.json"), + ). + Route("POST /api/v2/zones/456789/deployments", + servermock.Noop(). + WithStatusCode(http.StatusUnauthorized), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("POST /api/v2/sessions", + servermock.ResponseFromInternal("postSession.json"), + servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"), + ). + Route("DELETE /api/v2/resourceRecords/12345", + servermock.ResponseFromInternal("deleteResourceRecord.json"), + ). + Route("POST /api/v2/zones/456789/deployments", + servermock.ResponseFromInternal("postZoneDeployment.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromInternal("postZoneDeployment-request.json"), + ). + Build(t) + + provider.zoneIDs["abc"] = 456789 + provider.recordIDs["abc"] = 12345 + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp_skipDeploy(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(map[string]string{ + EnvSkipDeploy: "true", + }) + + provider := mockBuilder(). + Route("POST /api/v2/sessions", + servermock.ResponseFromInternal("postSession.json"), + servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"), + ). + Route("DELETE /api/v2/resourceRecords/12345", + servermock.ResponseFromInternal("deleteResourceRecord.json"), + ). + Route("POST /api/v2/zones/456789/deployments", + servermock.Noop(). + WithStatusCode(http.StatusUnauthorized), + ). + Build(t) + + provider.zoneIDs["abc"] = 456789 + provider.recordIDs["abc"] = 12345 + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/bluecatv2/internal/client.go b/providers/dns/bluecatv2/internal/client.go new file mode 100644 index 000000000..d3c801154 --- /dev/null +++ b/providers/dns/bluecatv2/internal/client.go @@ -0,0 +1,221 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" + querystring "github.com/google/go-querystring/query" +) + +// Client the Bluecat v2 API client. +type Client struct { + username string + password string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(serverURL, username, password string) (*Client, error) { + if serverURL == "" { + return nil, errors.New("server URL missing") + } + + if username == "" || password == "" { + return nil, errors.New("credentials missing") + } + + baseURL, err := url.Parse(serverURL) + if err != nil { + return nil, err + } + + return &Client{ + username: username, + password: password, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// RetrieveZones retrieves all zones. +func (c *Client) RetrieveZones(ctx context.Context, opts *CollectionOptions) ([]ZoneResource, error) { + endpoint := c.baseURL.JoinPath("api", "v2", "zones") + + collection, err := retrieveCollection[ZoneResource](ctx, c, endpoint, opts) + if err != nil { + return nil, err + } + + return collection.Data, nil +} + +// RetrieveZoneDeployments retrieves all deployments for a zone. +func (c *Client) RetrieveZoneDeployments(ctx context.Context, zoneID int64, opts *CollectionOptions) ([]QuickDeployment, error) { + endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "deployments") + + collection, err := retrieveCollection[QuickDeployment](ctx, c, endpoint, opts) + if err != nil { + return nil, err + } + + return collection.Data, nil +} + +// CreateZoneDeployment creates a new deployment for a zone. +func (c *Client) CreateZoneDeployment(ctx context.Context, zoneID int64) (*QuickDeployment, error) { + endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "deployments") + + payload := CommonResource{ + Type: "QuickDeployment", + } + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload) + if err != nil { + return nil, err + } + + result := new(QuickDeployment) + + err = c.doAuthenticated(ctx, req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// CreateZoneResourceRecord creates a new TXT record in a zone. +func (c *Client) CreateZoneResourceRecord(ctx context.Context, zoneID int64, record RecordTXT) (*RecordTXT, error) { + endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "resourceRecords") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return nil, err + } + + result := new(RecordTXT) + + err = c.doAuthenticated(ctx, req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// DeleteResourceRecord deletes a resource record. +func (c *Client) DeleteResourceRecord(ctx context.Context, recordID int64) error { + endpoint := c.baseURL.JoinPath("api", "v2", "resourceRecords", strconv.FormatInt(recordID, 10)) + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + return c.doAuthenticated(ctx, req, nil) +} + +func (c *Client) do(req *http.Request, result any) error { + useragent.SetHeader(req.Header) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func retrieveCollection[T any](ctx context.Context, client *Client, endpoint *url.URL, opts *CollectionOptions) (*Collection[T], error) { + if opts != nil { + values, err := querystring.Values(opts) + if err != nil { + return nil, err + } + + endpoint.RawQuery = values.Encode() + } + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + result := &Collection[T]{} + + err = client.doAuthenticated(ctx, req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/bluecatv2/internal/client_test.go b/providers/dns/bluecatv2/internal/client_test.go new file mode 100644 index 000000000..2559af66e --- /dev/null +++ b/providers/dns/bluecatv2/internal/client_test.go @@ -0,0 +1,208 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilderAuthenticated() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "userA", "secret") + if err != nil { + return nil, err + } + + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + servermock.CheckHeader(). + WithAuthorization("Basic secretToken"), + ) +} + +func TestClient_RetrieveZones(t *testing.T) { + client := mockBuilderAuthenticated(). + Route("GET /api/v2/zones", + servermock.ResponseFromFixture("zones.json"), + servermock.CheckQueryParameter().Strict(). + With( + "filter", + "absoluteName:eq('example.com') and configuration.name:eq('myConfiguration') and view.name:eq('myView')", + ), + ). + Build(t) + + opts := &CollectionOptions{ + Filter: And( + Eq("absoluteName", "example.com"), + Eq("configuration.name", "myConfiguration"), + Eq("view.name", "myView"), + ).String(), + } + + result, err := client.RetrieveZones(mockToken(t.Context()), opts) + require.NoError(t, err) + + expected := []ZoneResource{ + { + CommonResource: CommonResource{ID: 12345, Type: "ENUMZone", Name: "5678"}, + AbsoluteName: "string", + }, + { + CommonResource: CommonResource{ID: 12345, Type: "ExternalHostsZone", Name: "name"}, + }, + { + CommonResource: CommonResource{ID: 12345, Type: "InternalRootZone", Name: "name"}, + }, + { + CommonResource: CommonResource{ID: 12345, Type: "ResponsePolicyZone", Name: "name"}, + }, + { + CommonResource: CommonResource{ID: 12345, Type: "Zone", Name: "example.com"}, + AbsoluteName: "example.com", + }, + } + + assert.Equal(t, expected, result) +} + +func TestClient_RetrieveZones_error(t *testing.T) { + client := mockBuilderAuthenticated(). + Route("GET /api/v2/zones", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized), + ). + Build(t) + + opts := &CollectionOptions{ + Filter: And( + Eq("absoluteName", "example.com"), + Eq("configuration.name", "myConfiguration"), + Eq("view.name", "myView"), + ).String(), + } + + _, err := client.RetrieveZones(mockToken(t.Context()), opts) + require.EqualError(t, err, "401: Unauthorized: InvalidAuthorizationToken: The provided authorization token is invalid") +} + +func TestClient_RetrieveZoneDeployments(t *testing.T) { + client := mockBuilderAuthenticated(). + Route("GET /api/v2/zones/456789/deployments", + servermock.ResponseFromFixture("getZoneDeployments.json"), + servermock.CheckQueryParameter().Strict(). + With("filter", "id:eq('12345')"), + ). + Build(t) + + opts := &CollectionOptions{ + Filter: Eq("id", "12345").String(), + } + + result, err := client.RetrieveZoneDeployments(mockToken(t.Context()), 456789, opts) + require.NoError(t, err) + + expected := []QuickDeployment{ + { + CommonResource: CommonResource{ID: 12345, Type: "QuickDeployment", Name: ""}, + State: "PENDING", + Status: "CANCEL", + Message: "string", + PercentComplete: 50, + CreationDateTime: time.Date(2022, time.November, 23, 2, 53, 0, 0, time.UTC), + StartDateTime: time.Date(2022, time.November, 23, 2, 53, 3, 0, time.UTC), + CompletionDateTime: time.Date(2022, time.November, 23, 2, 54, 5, 0, time.UTC), + Method: "SCHEDULED", + }, + } + + assert.Equal(t, expected, result) +} + +func TestClient_CreateZoneDeployment(t *testing.T) { + client := mockBuilderAuthenticated(). + Route("POST /api/v2/zones/12345/deployments", + servermock.ResponseFromFixture("postZoneDeployment.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromFixture("postZoneDeployment-request.json"), + ). + Build(t) + + quickDeployment, err := client.CreateZoneDeployment(mockToken(t.Context()), 12345) + require.NoError(t, err) + + expected := &QuickDeployment{ + CommonResource: CommonResource{ID: 12345, Type: "QuickDeployment"}, + State: "PENDING", + Status: "CANCEL", + Message: "string", + PercentComplete: 50, + CreationDateTime: time.Date(2022, time.November, 23, 2, 53, 0, 0, time.UTC), + StartDateTime: time.Date(2022, time.November, 23, 2, 53, 3, 0, time.UTC), + CompletionDateTime: time.Date(2022, time.November, 23, 2, 54, 5, 0, time.UTC), + Method: "SCHEDULED", + } + + assert.Equal(t, expected, quickDeployment) +} + +func TestClient_CreateZoneResourceRecord(t *testing.T) { + client := mockBuilderAuthenticated(). + Route("POST /api/v2/zones/12345/resourceRecords", + servermock.ResponseFromFixture("postZoneResourceRecord.json"), + servermock.CheckRequestJSONBodyFromFixture("postZoneResourceRecord-request.json"), + ). + Build(t) + + record := RecordTXT{ + CommonResource: CommonResource{ + Type: "TXTRecord", + Name: "_acme-challenge", + }, + Text: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 120, + RecordType: "TXT", + } + + result, err := client.CreateZoneResourceRecord(mockToken(t.Context()), 12345, record) + require.NoError(t, err) + + expected := &RecordTXT{ + CommonResource: CommonResource{ + ID: 12345, + Type: "ResourceRecord", + Name: "name", + }, + TTL: 3600, + AbsoluteName: "host1.example.com", + Comment: "Sample comment.", + Dynamic: true, + RecordType: "CNAME", + Text: "", + } + + assert.Equal(t, expected, result) +} + +func TestClient_DeleteResourceRecord(t *testing.T) { + client := mockBuilderAuthenticated(). + Route("DELETE /api/v2/resourceRecords/12345", + servermock.ResponseFromFixture("deleteResourceRecord.json"), + ). + Build(t) + + err := client.DeleteResourceRecord(mockToken(t.Context()), 12345) + require.NoError(t, err) +} diff --git a/providers/dns/bluecatv2/internal/fixtures/deleteResourceRecord.json b/providers/dns/bluecatv2/internal/fixtures/deleteResourceRecord.json new file mode 100644 index 000000000..38ae2db6e --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/deleteResourceRecord.json @@ -0,0 +1,75 @@ +{ + "id": 12345, + "type": "WorkflowRequest", + "state": "APPROVED", + "operation": "ADD_ALIAS_RECORD", + "creator": { + "id": 103307, + "type": "User", + "name": "admin", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "authenticator": { + "id": 12345, + "type": "Authenticator", + "name": "LDAP authenticator" + }, + "email": "user@example.com", + "phoneNumber": "555-1234", + "securityPrivilege": "NO_ACCESS", + "historyPrivilege": "HIDE", + "accessType": "GUI", + "passwordResetRequired": true, + "accountLocked": true, + "x509Required": true, + "administrativeAccessRights": [ + { + "resourceType": "Event", + "accessLevel": "HIDE" + } + ] + }, + "resourceId": 0, + "resourceType": "ACL", + "fieldUpdates": [ + { + "name": "string", + "value": {}, + "previousValue": {} + } + ], + "dependentRequest": "string", + "modifier": { + "id": 103307, + "type": "User", + "name": "admin", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "authenticator": { + "id": 12345, + "type": "Authenticator", + "name": "LDAP authenticator" + }, + "email": "user@example.com", + "phoneNumber": "555-1234", + "securityPrivilege": "NO_ACCESS", + "historyPrivilege": "HIDE", + "accessType": "GUI", + "passwordResetRequired": true, + "accountLocked": true, + "x509Required": true, + "administrativeAccessRights": [ + { + "resourceType": "Event", + "accessLevel": "HIDE" + } + ] + }, + "creationDateTime": "2022-10-17T19:11:45Z", + "modificationDateTime": "2022-10-18T19:11:45Z", + "comment": "Sample comment." +} diff --git a/providers/dns/bluecatv2/internal/fixtures/error.json b/providers/dns/bluecatv2/internal/fixtures/error.json new file mode 100644 index 000000000..d3d2b8b5f --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/error.json @@ -0,0 +1,6 @@ +{ + "status": 401, + "reason": "Unauthorized", + "code": "InvalidAuthorizationToken", + "message": "The provided authorization token is invalid" +} diff --git a/providers/dns/bluecatv2/internal/fixtures/getZoneDeployments.json b/providers/dns/bluecatv2/internal/fixtures/getZoneDeployments.json new file mode 100644 index 000000000..b1a4938ad --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/getZoneDeployments.json @@ -0,0 +1,46 @@ +{ + "count": 0, + "totalCount": 0, + "data": [ + { + "id": 12345, + "type": "QuickDeployment", + "state": "PENDING", + "status": "CANCEL", + "message": "string", + "percentComplete": 50, + "creationDateTime": "2022-11-23T02:53:00Z", + "startDateTime": "2022-11-23T02:53:03Z", + "completionDateTime": "2022-11-23T02:54:05Z", + "user": { + "id": 103307, + "type": "User", + "name": "admin", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "authenticator": { + "id": 12345, + "type": "Authenticator", + "name": "LDAP authenticator" + }, + "email": "user@example.com", + "phoneNumber": "555-1234", + "securityPrivilege": "NO_ACCESS", + "historyPrivilege": "HIDE", + "accessType": "GUI", + "passwordResetRequired": true, + "accountLocked": true, + "x509Required": true, + "administrativeAccessRights": [ + { + "resourceType": "Event", + "accessLevel": "HIDE" + } + ] + }, + "method": "SCHEDULED" + } + ] +} diff --git a/providers/dns/bluecatv2/internal/fixtures/postSession-request.json b/providers/dns/bluecatv2/internal/fixtures/postSession-request.json new file mode 100644 index 000000000..e62048eb9 --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/postSession-request.json @@ -0,0 +1,4 @@ +{ + "username": "userA", + "password": "secret" +} diff --git a/providers/dns/bluecatv2/internal/fixtures/postSession.json b/providers/dns/bluecatv2/internal/fixtures/postSession.json new file mode 100644 index 000000000..4599ad0ad --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/postSession.json @@ -0,0 +1,50 @@ +{ + "id": 12345, + "type": "UserSession", + "apiToken": "VZoO2Z0BjBaJyvuhE4vNJRWqI9upwDHk70UNi0Ez", + "apiTokenExpirationDateTime": "2022-09-15T17:52:07Z", + "basicAuthenticationCredentials": "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=", + "remoteAddress": "192.168.1.1", + "readOnly": true, + "loginDateTime": "2022-09-14T17:45:03Z", + "logoutDateTime": "2022-09-14T19:45:03Z", + "state": "LOGGED_IN", + "response": "Authentication Error: Ensure that your username and password are correct.", + "user": { + "id": 103307, + "type": "User", + "name": "admin", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "authenticator": { + "id": 12345, + "type": "Authenticator", + "name": "LDAP authenticator" + }, + "email": "user@example.com", + "phoneNumber": "555-1234", + "securityPrivilege": "NO_ACCESS", + "historyPrivilege": "HIDE", + "accessType": "GUI", + "passwordResetRequired": true, + "accountLocked": true, + "x509Required": true, + "administrativeAccessRights": [ + { + "resourceType": "Event", + "accessLevel": "HIDE" + } + ] + }, + "authenticator": { + "id": 12345, + "type": "Authenticator", + "name": "LDAP authenticator", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + } + } +} diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment-request.json b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment-request.json new file mode 100644 index 000000000..099573a84 --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment-request.json @@ -0,0 +1,3 @@ +{ + "type": "QuickDeployment" +} diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment.json b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment.json new file mode 100644 index 000000000..fd26781fb --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment.json @@ -0,0 +1,40 @@ +{ + "id": 12345, + "type": "QuickDeployment", + "state": "PENDING", + "status": "CANCEL", + "message": "string", + "percentComplete": 50, + "creationDateTime": "2022-11-23T02:53:00Z", + "startDateTime": "2022-11-23T02:53:03Z", + "completionDateTime": "2022-11-23T02:54:05Z", + "user": { + "id": 103307, + "type": "User", + "name": "admin", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "authenticator": { + "id": 12345, + "type": "Authenticator", + "name": "LDAP authenticator" + }, + "email": "user@example.com", + "phoneNumber": "555-1234", + "securityPrivilege": "NO_ACCESS", + "historyPrivilege": "HIDE", + "accessType": "GUI", + "passwordResetRequired": true, + "accountLocked": true, + "x509Required": true, + "administrativeAccessRights": [ + { + "resourceType": "Event", + "accessLevel": "HIDE" + } + ] + }, + "method": "SCHEDULED" +} diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord-request.json b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord-request.json new file mode 100644 index 000000000..2de733c71 --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord-request.json @@ -0,0 +1,7 @@ +{ + "type": "TXTRecord", + "name": "_acme-challenge", + "ttl": 120, + "recordType": "TXT", + "text": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" +} diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord.json b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord.json new file mode 100644 index 000000000..78d028ee3 --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord.json @@ -0,0 +1,25 @@ +{ + "id": 12345, + "type": "ResourceRecord", + "name": "name", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "ttl": 3600, + "absoluteName": "host1.example.com", + "comment": "Sample comment.", + "dynamic": true, + "recordType": "CNAME", + "linkedRecord": { + "id": 12345, + "type": "ResourceRecord", + "name": "name", + "absoluteName": "host1.example.com" + } +} diff --git a/providers/dns/bluecatv2/internal/fixtures/zones.json b/providers/dns/bluecatv2/internal/fixtures/zones.json new file mode 100644 index 000000000..b9f2dfa8f --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/zones.json @@ -0,0 +1,185 @@ +{ + "count": 0, + "totalCount": 0, + "data": [ + { + "id": 12345, + "type": "ENUMZone", + "name": "5678", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "view": { + "id": 12345, + "type": "View", + "name": "default", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "deviceRegistrationEnabled": true, + "deviceRegistrationPortalAddress": "10.10.10.10" + }, + "deploymentEnabled": true, + "absoluteName": "string" + }, + { + "id": 12345, + "type": "ExternalHostsZone", + "name": "name", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "view": { + "id": 12345, + "type": "View", + "name": "default", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "deviceRegistrationEnabled": true, + "deviceRegistrationPortalAddress": "10.10.10.10" + } + }, + { + "id": 12345, + "type": "InternalRootZone", + "name": "name", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "view": { + "id": 12345, + "type": "View", + "name": "default", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "deviceRegistrationEnabled": true, + "deviceRegistrationPortalAddress": "10.10.10.10" + }, + "deploymentEnabled": true + }, + { + "id": 12345, + "type": "ResponsePolicyZone", + "name": "name", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "view": { + "id": 12345, + "type": "View", + "name": "default", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "deviceRegistrationEnabled": true, + "deviceRegistrationPortalAddress": "10.10.10.10" + }, + "responsePolicyZoneType": "LOCAL", + "responsePolicy": { + "id": 12345, + "type": "ResponsePolicy", + "name": "Block Response Policy" + }, + "overridePolicyType": "ALLOWLIST", + "overrideRefreshTime": "string", + "redirectTarget": "string", + "feedCategories": [ + "string" + ] + }, + { + "id": 12345, + "type": "Zone", + "name": "example.com", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "view": { + "id": 12345, + "type": "View", + "name": "default", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "deviceRegistrationEnabled": true, + "deviceRegistrationPortalAddress": "10.10.10.10" + }, + "deploymentEnabled": true, + "dynamicUpdateEnabled": true, + "template": { + "id": 12345, + "type": "ZoneTemplate", + "name": "name" + }, + "signed": true, + "signingPolicy": { + "id": 12345, + "type": "DNSSECSigningPolicy", + "name": "name" + }, + "absoluteName": "example.com" + } + ] +} diff --git a/providers/dns/bluecatv2/internal/identity.go b/providers/dns/bluecatv2/internal/identity.go new file mode 100644 index 000000000..af9355ab2 --- /dev/null +++ b/providers/dns/bluecatv2/internal/identity.go @@ -0,0 +1,60 @@ +package internal + +import ( + "context" + "fmt" + "net/http" +) + +type token string + +const tokenKey token = "token" + +const authorizationHeader = "Authorization" + +// CreateSession creates a new session. +func (c *Client) CreateSession(ctx context.Context, info LoginInfo) (*Session, error) { + endpoint := c.baseURL.JoinPath("api", "v2", "sessions") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, info) + if err != nil { + return nil, err + } + + result := new(Session) + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// CreateAuthenticatedContext creates a new authenticated context. +func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) { + tok, err := c.CreateSession(ctx, LoginInfo{Username: c.username, Password: c.password}) + if err != nil { + return nil, fmt.Errorf("create session: %w", err) + } + + return context.WithValue(ctx, tokenKey, tok.BasicAuthenticationCredentials), nil +} + +func (c *Client) doAuthenticated(ctx context.Context, req *http.Request, result any) error { + tok := getToken(ctx) + if tok != "" { + req.Header.Set(authorizationHeader, "Basic "+tok) + } + + return c.do(req, result) +} + +func getToken(ctx context.Context) string { + tok, ok := ctx.Value(tokenKey).(string) + if !ok { + return "" + } + + return tok +} diff --git a/providers/dns/bluecatv2/internal/identity_test.go b/providers/dns/bluecatv2/internal/identity_test.go new file mode 100644 index 000000000..3a1c4d2a2 --- /dev/null +++ b/providers/dns/bluecatv2/internal/identity_test.go @@ -0,0 +1,82 @@ +package internal + +import ( + "context" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "userA", "secret") + if err != nil { + return nil, err + } + + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + ) +} + +func mockToken(ctx context.Context) context.Context { + return context.WithValue(ctx, tokenKey, "secretToken") +} + +func TestClient_CreateSession(t *testing.T) { + client := mockBuilder(). + Route("POST /api/v2/sessions", + servermock.ResponseFromFixture("postSession.json"), + servermock.CheckRequestJSONBodyFromFixture("postSession-request.json"), + ). + Build(t) + + info := LoginInfo{ + Username: "userA", + Password: "secret", + } + + result, err := client.CreateSession(mockToken(t.Context()), info) + require.NoError(t, err) + + expected := &Session{ + ID: 12345, + Type: "UserSession", + APIToken: "VZoO2Z0BjBaJyvuhE4vNJRWqI9upwDHk70UNi0Ez", + APITokenExpirationDateTime: time.Date(2022, time.September, 15, 17, 52, 7, 0, time.UTC), + BasicAuthenticationCredentials: "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=", + RemoteAddress: "192.168.1.1", + ReadOnly: true, + LoginDateTime: time.Date(2022, time.September, 14, 17, 45, 3, 0, time.UTC), + LogoutDateTime: time.Date(2022, time.September, 14, 19, 45, 3, 0, time.UTC), + State: "LOGGED_IN", + Response: "Authentication Error: Ensure that your username and password are correct.", + } + + assert.Equal(t, expected, result) +} + +func TestClient_CreateAuthenticatedContext(t *testing.T) { + client := mockBuilder(). + Route("POST /api/v2/sessions", + servermock.ResponseFromFixture("postSession.json"), + servermock.CheckRequestJSONBodyFromFixture("postSession-request.json"), + ). + Build(t) + + ctx, err := client.CreateAuthenticatedContext(t.Context()) + require.NoError(t, err) + + assert.Equal(t, "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=", getToken(ctx)) +} diff --git a/providers/dns/bluecatv2/internal/predicates.go b/providers/dns/bluecatv2/internal/predicates.go new file mode 100644 index 000000000..8ed6f714b --- /dev/null +++ b/providers/dns/bluecatv2/internal/predicates.go @@ -0,0 +1,64 @@ +package internal + +import ( + "fmt" + "strings" +) + +type Predicate struct { + field string + operator string + values []string +} + +func (p *Predicate) String() string { + var values []string + for _, v := range p.values { + values = append(values, fmt.Sprintf("'%s'", v)) + } + + return fmt.Sprintf("%s:%s(%s)", p.field, p.operator, strings.Join(values, ", ")) +} + +func Eq(field, value string) *Predicate { + return &Predicate{field: field, operator: "eq", values: []string{value}} +} + +func Contains(field, value string) *Predicate { + return &Predicate{field: field, operator: "contains", values: []string{value}} +} + +func StartsWith(field, value string) *Predicate { + return &Predicate{field: field, operator: "startsWith", values: []string{value}} +} + +func EndsWith(field, value string) *Predicate { + return &Predicate{field: field, operator: "endsWith", values: []string{value}} +} + +func In(field string, values ...string) *Predicate { + return &Predicate{field: field, operator: "in", values: values} +} + +type Combined struct { + predicates []*Predicate + operator string +} + +func (o *Combined) String() string { + var parts []string + + for _, predicate := range o.predicates { + parts = append(parts, predicate.String()) + } + + return strings.Join(parts, " "+o.operator+" ") +} + +func And(predicates ...*Predicate) *Combined { + return &Combined{predicates: predicates, operator: "and"} +} + +func Or(predicates ...*Predicate) *Combined { + return &Combined{predicates: predicates, operator: "or"} +} diff --git a/providers/dns/bluecatv2/internal/predicates_test.go b/providers/dns/bluecatv2/internal/predicates_test.go new file mode 100644 index 000000000..6913e8729 --- /dev/null +++ b/providers/dns/bluecatv2/internal/predicates_test.go @@ -0,0 +1,78 @@ +package internal + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPredicate(t *testing.T) { + testCases := []struct { + desc string + predicate fmt.Stringer + expected string + }{ + { + desc: "Equals", + predicate: Eq("foo", "bar"), + expected: "foo:eq('bar')", + }, + { + desc: "Contains", + predicate: Contains("foo", "bar"), + expected: "foo:contains('bar')", + }, + { + desc: "Starts with", + predicate: StartsWith("foo", "bar"), + expected: "foo:startsWith('bar')", + }, + { + desc: "Ends with", + predicate: EndsWith("foo", "bar"), + expected: "foo:endsWith('bar')", + }, + { + desc: "Match a list of values", + predicate: In("foo", "bar", "bir"), + expected: "foo:in('bar', 'bir')", + }, + { + desc: "Combined: and", + predicate: And(Eq("foo", "bar"), Eq("fii", "bir")), + expected: "foo:eq('bar') and fii:eq('bir')", + }, + { + desc: "Combined: multiple and", + predicate: And( + Eq("foo", "bar"), + Eq("fii", "bir"), + Eq("fuu", "bur"), + ), + expected: "foo:eq('bar') and fii:eq('bir') and fuu:eq('bur')", + }, + { + desc: "Combined: or", + predicate: Or(Eq("foo", "bar"), Eq("foo", "bir")), + expected: "foo:eq('bar') or foo:eq('bir')", + }, + { + desc: "Combined: multiple or", + predicate: Or( + Eq("foo", "bar"), + Eq("foo", "bir"), + Eq("foo", "bur"), + ), + expected: "foo:eq('bar') or foo:eq('bir') or foo:eq('bur')", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.expected, test.predicate.String()) + }) + } +} diff --git a/providers/dns/bluecatv2/internal/types.go b/providers/dns/bluecatv2/internal/types.go new file mode 100644 index 000000000..562fd60b0 --- /dev/null +++ b/providers/dns/bluecatv2/internal/types.go @@ -0,0 +1,122 @@ +package internal + +import ( + "fmt" + "time" +) + +// Quick deployment states. +// +//nolint:misspell // US vs UK +const ( + QDStatePending = "PENDING" + QDStateQueued = "QUEUED" + QDStateRunning = "RUNNING" + QDStateCancelled = "CANCELLED" + QDStateCancelling = "CANCELLING" + QDStateCompleted = "COMPLETED" + QDStateCompletedWithErrors = "COMPLETED_WITH_ERRORS" + QDStateCompletedWithWarnings = "COMPLETED_WITH_WARNINGS" + QDStateFailed = "FAILED" + QDStateUnknown = "UNKNOWN" +) + +// APIError represents an error. +// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Errors/9.6.0 +type APIError struct { + Status int `json:"status"` + Reason string `json:"reason"` + Code string `json:"code"` + Message string `json:"message"` +} + +func (a *APIError) Error() string { + return fmt.Sprintf("%d: %s: %s: %s", a.Status, a.Reason, a.Code, a.Message) +} + +// CommonResource represents the common resource fields. +// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Resources/9.6.0 +type CommonResource struct { + ID int64 `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` +} + +// Collection represents a collection of resources. +// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Collections/9.6.0 +type Collection[T any] struct { + Count int64 `json:"count"` + TotalCount int64 `json:"totalCount"` + Data []T `json:"data"` +} + +type CollectionOptions struct { + // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Fields/9.6.0 + Fields string `url:"fields,omitempty"` + + // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Pagination/9.6.0 + Limit int `url:"limit,omitempty"` + Offset int `url:"offset,omitempty"` + + // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Filter/9.6.0 + Filter string `url:"filter,omitempty"` + + // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Ordering/9.6.0 + OrderBy string `url:"orderBy,omitempty"` + + // Should return or not the total number of resources matching the query. + Total bool `url:"total,omitempty"` +} + +type RecordTXT struct { + CommonResource + + TTL int `json:"ttl,omitempty"` + AbsoluteName string `json:"absoluteName,omitempty"` + Comment string `json:"comment,omitempty"` + Dynamic bool `json:"dynamic,omitempty"` + RecordType string `json:"recordType,omitempty"` + Text string `json:"text,omitempty"` +} + +type ZoneResource struct { + CommonResource + + AbsoluteName string `json:"absoluteName,omitempty"` +} + +type QuickDeployment struct { + CommonResource + + State string `json:"state,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + PercentComplete int `json:"percentComplete,omitempty"` + CreationDateTime time.Time `json:"creationDateTime,omitzero"` + StartDateTime time.Time `json:"startDateTime,omitzero"` + CompletionDateTime time.Time `json:"completionDateTime,omitzero"` + Method string `json:"method,omitempty"` +} + +// LoginInfo represents the login information. +// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Creating-an-API-session/9.6.0 +type LoginInfo struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// Session represents the session. +// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Creating-an-API-session/9.6.0 +type Session struct { + ID int `json:"id"` + Type string `json:"type"` + APIToken string `json:"apiToken"` + APITokenExpirationDateTime time.Time `json:"apiTokenExpirationDateTime"` + BasicAuthenticationCredentials string `json:"basicAuthenticationCredentials"` + RemoteAddress string `json:"remoteAddress"` + ReadOnly bool `json:"readOnly"` + LoginDateTime time.Time `json:"loginDateTime"` + LogoutDateTime time.Time `json:"logoutDateTime"` + State string `json:"state"` + Response string `json:"response"` +} diff --git a/providers/dns/bookmyname/bookmyname.go b/providers/dns/bookmyname/bookmyname.go new file mode 100644 index 000000000..6f42dfd78 --- /dev/null +++ b/providers/dns/bookmyname/bookmyname.go @@ -0,0 +1,141 @@ +// Package bookmyname implements a DNS provider for solving the DNS-01 challenge using BookMyName. +package bookmyname + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/bookmyname/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "BOOKMYNAME_" + + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "PASSWORD" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Username string + Password string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for BookMyName. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvPassword) + if err != nil { + return nil, fmt.Errorf("bookmyname: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for BookMyName. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("bookmyname: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.Username, config.Password) + if err != nil { + return nil, fmt.Errorf("bookmyname: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + record := internal.Record{ + Hostname: dns01.UnFqdn(info.EffectiveFQDN), + Type: "txt", + TTL: d.config.TTL, + Value: info.Value, + } + + err := d.client.AddRecord(context.Background(), record) + if err != nil { + return fmt.Errorf("bookmyname: add record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + record := internal.Record{ + Hostname: dns01.UnFqdn(info.EffectiveFQDN), + Type: "txt", + TTL: d.config.TTL, + Value: info.Value, + } + + err := d.client.RemoveRecord(context.Background(), record) + if err != nil { + return fmt.Errorf("bookmyname: add record: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/bookmyname/bookmyname.toml b/providers/dns/bookmyname/bookmyname.toml new file mode 100644 index 000000000..76fcb85e7 --- /dev/null +++ b/providers/dns/bookmyname/bookmyname.toml @@ -0,0 +1,24 @@ +Name = "BookMyName" +Description = '''''' +URL = "https://www.bookmyname.com/" +Code = "bookmyname" +Since = "v4.23.0" + +Example = ''' +BOOKMYNAME_USERNAME="xxx" \ +BOOKMYNAME_PASSWORD="yyy" \ +lego --dns bookmyname -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + BOOKMYNAME_USERNAME = "Username" + BOOKMYNAME_PASSWORD = "Password" + [Configuration.Additional] + BOOKMYNAME_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + BOOKMYNAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + BOOKMYNAME_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + BOOKMYNAME_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://fr.faqs.bookmyname.com/frfaqs/dyndns" diff --git a/providers/dns/iwantmyname/iwantmyname_test.go b/providers/dns/bookmyname/bookmyname_test.go similarity index 80% rename from providers/dns/iwantmyname/iwantmyname_test.go rename to providers/dns/bookmyname/bookmyname_test.go index 7ae4545b2..8b3fa21e6 100644 --- a/providers/dns/iwantmyname/iwantmyname_test.go +++ b/providers/dns/bookmyname/bookmyname_test.go @@ -1,4 +1,4 @@ -package iwantmyname +package bookmyname import ( "testing" @@ -9,8 +9,7 @@ import ( const envDomain = envNamespace + "DOMAIN" -var envTest = tester.NewEnvTest(EnvUsername, EnvPassword). - WithDomain(envDomain) +var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { @@ -25,30 +24,33 @@ func TestNewDNSProvider(t *testing.T) { EnvPassword: "secret", }, }, - { - desc: "missing credentials", - envVars: map[string]string{}, - expected: "iwantmyname: some credentials information are missing: IWANTMYNAME_USERNAME,IWANTMYNAME_PASSWORD", - }, { desc: "missing username", envVars: map[string]string{ + EnvUsername: "", EnvPassword: "secret", }, - expected: "iwantmyname: some credentials information are missing: IWANTMYNAME_USERNAME", + expected: "bookmyname: some credentials information are missing: BOOKMYNAME_USERNAME", }, { - desc: "missing password", + desc: "missing paswword", envVars: map[string]string{ EnvUsername: "user", + EnvPassword: "", }, - expected: "iwantmyname: some credentials information are missing: IWANTMYNAME_PASSWORD", + expected: "bookmyname: some credentials information are missing: BOOKMYNAME_PASSWORD", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "bookmyname: some credentials information are missing: BOOKMYNAME_USERNAME,BOOKMYNAME_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -79,19 +81,19 @@ func TestNewDNSProviderConfig(t *testing.T) { username: "user", password: "secret", }, - { - desc: "missing credentials", - expected: "iwantmyname: credentials missing", - }, { desc: "missing username", password: "secret", - expected: "iwantmyname: credentials missing", + expected: "bookmyname: credentials missing", }, { desc: "missing password", username: "user", - expected: "iwantmyname: credentials missing", + expected: "bookmyname: credentials missing", + }, + { + desc: "missing credentials", + expected: "bookmyname: credentials missing", }, } @@ -121,6 +123,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -134,6 +137,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/bookmyname/internal/client.go b/providers/dns/bookmyname/internal/client.go new file mode 100644 index 000000000..08d4cccce --- /dev/null +++ b/providers/dns/bookmyname/internal/client.go @@ -0,0 +1,118 @@ +package internal + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + querystring "github.com/google/go-querystring/query" +) + +const defaultBaseURL = "https://www.bookmyname.com/dyndns/" + +// Client the BookMyName API client. +type Client struct { + username string + password string + + baseURL string + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(username, password string) (*Client, error) { + if username == "" || password == "" { + return nil, errors.New("credentials missing") + } + + return &Client{ + username: username, + password: password, + baseURL: defaultBaseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) AddRecord(ctx context.Context, record Record) error { + endpoint, err := c.createEndpoint(record, "add") + if err != nil { + return err + } + + err = c.do(ctx, endpoint) + if err != nil { + return err + } + + return nil +} + +func (c *Client) RemoveRecord(ctx context.Context, record Record) error { + endpoint, err := c.createEndpoint(record, "remove") + if err != nil { + return err + } + + err = c.do(ctx, endpoint) + if err != nil { + return err + } + + return nil +} + +func (c *Client) createEndpoint(record Record, action string) (*url.URL, error) { + endpoint, err := url.Parse(c.baseURL) + if err != nil { + return nil, fmt.Errorf("parse URL: %w", err) + } + + values, err := querystring.Values(record) + if err != nil { + return nil, fmt.Errorf("query parameters: %w", err) + } + + values.Set("do", action) + + endpoint.RawQuery = values.Encode() + + return endpoint, nil +} + +func (c *Client) do(ctx context.Context, endpoint *url.URL) error { + endpoint.User = url.UserPassword(c.username, c.password) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return fmt.Errorf("unable to create request: %w", err) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + if resp.StatusCode/100 != 2 { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + if !strings.HasPrefix(string(raw), "good: update done") && !strings.HasPrefix(string(raw), "good: remove done") { + return fmt.Errorf("unexpected response: %s", string(bytes.TrimSpace(raw))) + } + + return nil +} diff --git a/providers/dns/bookmyname/internal/client_test.go b/providers/dns/bookmyname/internal/client_test.go new file mode 100644 index 000000000..900d62fef --- /dev/null +++ b/providers/dns/bookmyname/internal/client_test.go @@ -0,0 +1,116 @@ +package internal + +import ( + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("user", "secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + client.baseURL = server.URL + + return client, nil + }, + servermock.CheckHeader(). + WithBasicAuth("user", "secret")) +} + +func TestClient_AddRecord(t *testing.T) { + client := mockBuilder(). + Route("GET /", + servermock.ResponseFromFixture("add_success.txt"), + servermock.CheckQueryParameter().Strict(). + With("do", "add"). + With("hostname", "_acme-challenge.sub.example.com."). + With("type", "txt"). + With("value", "test"). + With("ttl", "300"), + ). + Build(t) + + record := Record{ + Hostname: "_acme-challenge.sub.example.com.", + Type: "txt", + TTL: 300, + Value: "test", + } + + err := client.AddRecord(t.Context(), record) + require.NoError(t, err) +} + +func TestClient_AddRecord_error(t *testing.T) { + client := mockBuilder(). + Route("GET /", + servermock.ResponseFromFixture("error.txt"), + servermock.CheckQueryParameter(). + With("do", "add")). + Build(t) + + record := Record{ + Hostname: "_acme-challenge.sub.example.com.", + Type: "txt", + TTL: 300, + Value: "test", + } + + err := client.AddRecord(t.Context(), record) + require.Error(t, err) + + require.EqualError(t, err, "unexpected response: notfqdn: Host _acme-challenge.sub.example.com. malformed / vhn") +} + +func TestClient_RemoveRecord(t *testing.T) { + client := mockBuilder(). + Route("GET /", + servermock.ResponseFromFixture("remove_success.txt"), + servermock.CheckQueryParameter().Strict(). + With("do", "remove"). + With("hostname", "_acme-challenge.sub.example.com."). + With("type", "txt"). + With("value", "test"). + With("ttl", "300"), + ). + Build(t) + + record := Record{ + Hostname: "_acme-challenge.sub.example.com.", + Type: "txt", + TTL: 300, + Value: "test", + } + + err := client.RemoveRecord(t.Context(), record) + require.NoError(t, err) +} + +func TestClient_RemoveRecord_error(t *testing.T) { + client := mockBuilder(). + Route("GET /", + servermock.ResponseFromFixture("error.txt"), + servermock.CheckQueryParameter(). + With("do", "remove")). + Build(t) + + record := Record{ + Hostname: "_acme-challenge.sub.example.com.", + Type: "txt", + TTL: 300, + Value: "test", + } + + err := client.RemoveRecord(t.Context(), record) + require.Error(t, err) + + require.EqualError(t, err, "unexpected response: notfqdn: Host _acme-challenge.sub.example.com. malformed / vhn") +} diff --git a/providers/dns/bookmyname/internal/fixtures/add_success.txt b/providers/dns/bookmyname/internal/fixtures/add_success.txt new file mode 100644 index 000000000..76304fc24 --- /dev/null +++ b/providers/dns/bookmyname/internal/fixtures/add_success.txt @@ -0,0 +1 @@ +good: update done, cid 123, domain id 456, type txt, ip xxx diff --git a/providers/dns/bookmyname/internal/fixtures/error.txt b/providers/dns/bookmyname/internal/fixtures/error.txt new file mode 100644 index 000000000..3c62ede60 --- /dev/null +++ b/providers/dns/bookmyname/internal/fixtures/error.txt @@ -0,0 +1 @@ +notfqdn: Host _acme-challenge.sub.example.com. malformed / vhn diff --git a/providers/dns/bookmyname/internal/fixtures/remove_success.txt b/providers/dns/bookmyname/internal/fixtures/remove_success.txt new file mode 100644 index 000000000..1e83c6dcc --- /dev/null +++ b/providers/dns/bookmyname/internal/fixtures/remove_success.txt @@ -0,0 +1 @@ +good: remove done 1, cid 123, domain id 456, ttl 300, type txt, ip xxx diff --git a/providers/dns/bookmyname/internal/types.go b/providers/dns/bookmyname/internal/types.go new file mode 100644 index 000000000..96dab064a --- /dev/null +++ b/providers/dns/bookmyname/internal/types.go @@ -0,0 +1,8 @@ +package internal + +type Record struct { + Hostname string `url:"hostname"` + Type string `url:"type"` + TTL int `url:"ttl"` + Value string `url:"value"` +} diff --git a/providers/dns/brandit/brandit.go b/providers/dns/brandit/brandit.go index 5d7b23d01..fe3b52239 100644 --- a/providers/dns/brandit/brandit.go +++ b/providers/dns/brandit/brandit.go @@ -9,9 +9,11 @@ import ( "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/brandit/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -27,6 +29,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -89,6 +93,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -162,6 +168,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordsMu.Lock() dnsRecord, ok := d.records[token] d.recordsMu.Unlock() + if !ok { return fmt.Errorf("brandit: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } @@ -180,6 +187,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } var recordID int + for i, r := range records.RR { if r == dnsRecord { recordID = i diff --git a/providers/dns/brandit/brandit.toml b/providers/dns/brandit/brandit.toml index 1c70eb1ca..4c43e27a9 100644 --- a/providers/dns/brandit/brandit.toml +++ b/providers/dns/brandit/brandit.toml @@ -12,7 +12,7 @@ Since = "v4.11.0" Example = ''' BRANDIT_API_KEY=xxxxxxxxxxxxxxxxxxxxx \ BRANDIT_API_USERNAME=yyyyyyyyyyyyyyyyyyyy \ -lego --email you@example.com --dns brandit -d '*.example.com' -d example.com run +lego --dns brandit -d '*.example.com' -d example.com run ''' [Configuration] @@ -20,10 +20,10 @@ lego --email you@example.com --dns brandit -d '*.example.com' -d example.com run BRANDIT_API_KEY = "The API key" BRANDIT_API_USERNAME = "The API username" [Configuration.Additional] - BRANDIT_POLLING_INTERVAL = "Time between DNS propagation check" - BRANDIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - BRANDIT_TTL = "The TTL of the TXT record used for the DNS challenge" - BRANDIT_HTTP_TIMEOUT = "API request timeout" + BRANDIT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + BRANDIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)" + BRANDIT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" + BRANDIT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://portal.brandit.com/apidocv3" diff --git a/providers/dns/brandit/brandit_test.go b/providers/dns/brandit/brandit_test.go index 156e7c3f4..40abdd3d0 100644 --- a/providers/dns/brandit/brandit_test.go +++ b/providers/dns/brandit/brandit_test.go @@ -48,6 +48,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -120,6 +121,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -133,6 +135,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/brandit/internal/client.go b/providers/dns/brandit/internal/client.go index 59c57419a..cda3be5a2 100644 --- a/providers/dns/brandit/internal/client.go +++ b/providers/dns/brandit/internal/client.go @@ -62,6 +62,7 @@ func (c *Client) ListRecords(ctx context.Context, account, dnsZone string) (*Lis query.Add("first", strconv.Itoa(result.Response.Last[0]+1)) tmp := &Response[*ListRecordsResponse]{} + err := c.do(ctx, query, tmp) if err != nil { return nil, err @@ -156,6 +157,7 @@ func (c *Client) do(ctx context.Context, query url.Values, result any) error { // Unmarshal the error response, because the API returns a 200 OK even if there is an error. var apiError APIError + err = json.Unmarshal(raw, &apiError) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) @@ -183,6 +185,7 @@ func sign(apiUsername, apiKey string, query url.Values) (url.Values, error) { canonicalRequest := fmt.Sprintf("%s%s%s", apiUsername, timestamp, defaultBaseURL) mac := hmac.New(sha256.New, []byte(apiKey)) + _, err := mac.Write([]byte(canonicalRequest)) if err != nil { return nil, err diff --git a/providers/dns/brandit/internal/client_test.go b/providers/dns/brandit/internal/client_test.go index a37e51a29..cb779ef68 100644 --- a/providers/dns/brandit/internal/client_test.go +++ b/providers/dns/brandit/internal/client_test.go @@ -1,52 +1,44 @@ package internal import ( - "context" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("user", "secret") + if err != nil { + return nil, err + } - server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } + client.HTTPClient = server.Client() + client.baseURL = server.URL - defer func() { _ = file.Close() }() - - rw.WriteHeader(http.StatusOK) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - })) - t.Cleanup(server.Close) - - client, err := NewClient("test_user", "apiKey") - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL = server.URL - - return client + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()) } func TestClient_StatusDomain(t *testing.T) { - client := setupTest(t, "status-domain.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("status-domain.json"), + servermock.CheckForm().Strict(). + WithRegexp("signature", "[a-z0-9]+"). + WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`). + With("command", "statusDomain"). + With("user", "user"). + With("domain", "example.com"), + ). + Build(t) - domain, err := client.StatusDomain(context.Background(), "example.com") + domain, err := client.StatusDomain(t.Context(), "example.com") require.NoError(t, err) expected := &StatusResponse{ @@ -80,16 +72,28 @@ func TestClient_StatusDomain(t *testing.T) { } func TestClient_StatusDomain_error(t *testing.T) { - client := setupTest(t, "error.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("error.json")). + Build(t) - _, err := client.StatusDomain(context.Background(), "example.com") + _, err := client.StatusDomain(t.Context(), "example.com") require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."}) } func TestClient_ListRecords(t *testing.T) { - client := setupTest(t, "list-records.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("list-records.json"), + servermock.CheckForm().Strict(). + WithRegexp("signature", "[a-z0-9]+"). + WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`). + With("account", "example"). + With("command", "listDNSRR"). + With("user", "user"). + With("dnszone", "example.com"), + ). + Build(t) - resp, err := client.ListRecords(context.Background(), "example", "example.com") + resp, err := client.ListRecords(t.Context(), "example", "example.com") require.NoError(t, err) expected := &ListRecordsResponse{ @@ -106,14 +110,28 @@ func TestClient_ListRecords(t *testing.T) { } func TestClient_ListRecords_error(t *testing.T) { - client := setupTest(t, "error.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("error.json")). + Build(t) - _, err := client.ListRecords(context.Background(), "example", "example.com") + _, err := client.ListRecords(t.Context(), "example", "example.com") require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."}) } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "add-record.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("add-record.json"), + servermock.CheckForm().Strict(). + WithRegexp("signature", "[a-z0-9]+"). + WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`). + With("account", "test"). + With("command", "addDNSRR"). + With("key", "2565"). + With("user", "user"). + With("rrdata", "example.com 600 IN TXT txttxttxt"). + With("dnszone", "example.com"), + ). + Build(t) testRecord := Record{ ID: 2565, @@ -122,7 +140,7 @@ func TestClient_AddRecord(t *testing.T) { Content: "txttxttxt", TTL: 600, } - resp, err := client.AddRecord(context.Background(), "example.com", "test", "2565", testRecord) + resp, err := client.AddRecord(t.Context(), "example.com", "test", "2565", testRecord) require.NoError(t, err) expected := &AddRecord{ @@ -140,7 +158,9 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "error.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("error.json")). + Build(t) testRecord := Record{ ID: 2565, @@ -150,20 +170,34 @@ func TestClient_AddRecord_error(t *testing.T) { TTL: 600, } - _, err := client.AddRecord(context.Background(), "example.com", "test", "2565", testRecord) + _, err := client.AddRecord(t.Context(), "example.com", "test", "2565", testRecord) require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."}) } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "delete-record.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("delete-record.json"), + servermock.CheckForm().Strict(). + WithRegexp("signature", "[a-z0-9]+"). + WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`). + With("account", "test"). + With("command", "deleteDNSRR"). + With("key", "2374"). + With("user", "user"). + With("rrdata", "example.com 600 IN TXT txttxttxt"). + With("dnszone", "example.com"), + ). + Build(t) - err := client.DeleteRecord(context.Background(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374") + err := client.DeleteRecord(t.Context(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "error.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("error.json")). + Build(t) - err := client.DeleteRecord(context.Background(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374") + err := client.DeleteRecord(t.Context(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374") require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."}) } diff --git a/providers/dns/bunny/bunny.go b/providers/dns/bunny/bunny.go index 2cf7ea54a..29949608b 100644 --- a/providers/dns/bunny/bunny.go +++ b/providers/dns/bunny/bunny.go @@ -5,15 +5,20 @@ import ( "context" "errors" "fmt" + "net/http" + "slices" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" "github.com/nrdcg/bunny-go" + "golang.org/x/net/publicsuffix" ) -const minTTL = 60 - // Environment variables names. const ( envNamespace = "BUNNY_" @@ -23,14 +28,21 @@ const ( EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const minTTL = 60 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { - APIKey string + APIKey string + PropagationTimeout time.Duration PollingInterval time.Duration TTL int + HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. @@ -38,7 +50,10 @@ func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, } } @@ -76,9 +91,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, fmt.Errorf("bunny: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } - client := bunny.NewClient(config.APIKey) + if config.HTTPClient == nil { + config.HTTPClient = &http.Client{Timeout: 30 * time.Second} + } - return &DNSProvider{config: config, client: client}, nil + config.HTTPClient = clientdebug.Wrap(config.HTTPClient) + + return &DNSProvider{ + config: config, + client: bunny.NewClient(config.APIKey, + bunny.WithUserAgent(useragent.Get()), + bunny.WithHTTPClient(config.HTTPClient), + ), + }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. @@ -91,32 +116,27 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - authZone, err := getZoneName(info.EffectiveFQDN) - if err != nil { - return fmt.Errorf("bunny: could not find zone for domain %q: %w", domain, err) - } - ctx := context.Background() - zone, err := d.findZone(ctx, authZone) + zone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("bunny: %w", err) } - subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, ptr.Deref(zone.Domain)) if err != nil { return fmt.Errorf("bunny: %w", err) } record := &bunny.AddOrUpdateDNSRecordOptions{ - Type: pointer(bunny.DNSRecordTypeTXT), - Name: pointer(subDomain), - Value: pointer(info.Value), - TTL: pointer(int32(d.config.TTL)), + Type: ptr.Pointer(bunny.DNSRecordTypeTXT), + Name: ptr.Pointer(subDomain), + Value: ptr.Pointer(info.Value), + TTL: ptr.Pointer(int32(d.config.TTL)), } - if _, err := d.client.DNSZone.AddDNSRecord(ctx, deref(zone.ID), record); err != nil { - return fmt.Errorf("bunny: failed to add TXT record: fqdn=%s, zoneID=%d: %w", info.EffectiveFQDN, deref(zone.ID), err) + if _, err := d.client.DNSZone.AddDNSRecord(ctx, ptr.Deref(zone.ID), record); err != nil { + return fmt.Errorf("bunny: failed to add TXT record: fqdn=%s, zoneID=%d: %w", info.EffectiveFQDN, ptr.Deref(zone.ID), err) } return nil @@ -126,38 +146,35 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - authZone, err := getZoneName(info.EffectiveFQDN) - if err != nil { - return fmt.Errorf("bunny: could not find zone for domain %q: %w", domain, err) - } - ctx := context.Background() - zone, err := d.findZone(ctx, authZone) + zone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("bunny: %w", err) } - subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, ptr.Deref(zone.Domain)) if err != nil { return fmt.Errorf("bunny: %w", err) } var record *bunny.DNSRecord + for _, r := range zone.Records { - if deref(r.Name) == subDomain && deref(r.Type) == bunny.DNSRecordTypeTXT { + if ptr.Deref(r.Name) == subDomain && ptr.Deref(r.Type) == bunny.DNSRecordTypeTXT { r := r record = &r + break } } if record == nil { - return fmt.Errorf("bunny: could not find TXT record zone=%d, subdomain=%s", deref(zone.ID), subDomain) + return fmt.Errorf("bunny: could not find TXT record zone=%d, subdomain=%s", ptr.Deref(zone.ID), subDomain) } - if err := d.client.DNSZone.DeleteDNSRecord(ctx, deref(zone.ID), deref(record.ID)); err != nil { - return fmt.Errorf("bunny: failed to delete TXT record: id=%d, name=%s: %w", deref(record.ID), deref(record.Name), err) + if err := d.client.DNSZone.DeleteDNSRecord(ctx, ptr.Deref(zone.ID), ptr.Deref(record.ID)); err != nil { + return fmt.Errorf("bunny: failed to delete TXT record: id=%d, name=%s: %w", ptr.Deref(record.ID), ptr.Deref(record.Name), err) } return nil @@ -169,37 +186,50 @@ func (d *DNSProvider) findZone(ctx context.Context, authZone string) (*bunny.DNS return nil, err } - var zone *bunny.DNSZone - for _, item := range zones.Items { - if item != nil && deref(item.Domain) == authZone { - zone = item - break - } - } - + zone := findZone(zones, authZone) if zone == nil { - return nil, fmt.Errorf("could not find DNSZone zone=%s", authZone) + return nil, fmt.Errorf("could not find DNSZone domain=%s", authZone) } return zone, nil } -func getZoneName(fqdn string) (string, error) { - authZone, err := dns01.FindZoneByFqdn(fqdn) - if err != nil { - return "", err +func findZone(zones *bunny.DNSZones, domain string) *bunny.DNSZone { + domains := possibleDomains(domain) + + var domainLength int + + var zone *bunny.DNSZone + + for _, item := range zones.Items { + if item == nil { + continue + } + + curr := ptr.Deref(item.Domain) + + if slices.Contains(domains, curr) && domainLength < len(curr) { + domainLength = len(curr) + + zone = item + } } - return dns01.UnFqdn(authZone), nil + return zone } -func pointer[T string | int | int32 | int64](v T) *T { return &v } +func possibleDomains(domain string) []string { + var domains []string -func deref[T string | int | int32 | int64](v *T) T { - if v == nil { - var zero T - return zero + tld, _ := publicsuffix.PublicSuffix(domain) + for d := range dns01.DomainsSeq(domain) { + if tld == d { + // skip the TLD + break + } + + domains = append(domains, dns01.UnFqdn(d)) } - return *v + return domains } diff --git a/providers/dns/bunny/bunny.toml b/providers/dns/bunny/bunny.toml index 22b119bbb..758c4f202 100644 --- a/providers/dns/bunny/bunny.toml +++ b/providers/dns/bunny/bunny.toml @@ -6,16 +6,17 @@ Since = "v4.11.0" Example = ''' BUNNY_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ -lego --email you@example.com --dns bunny -d '*.example.com' -d example.com run +lego --dns bunny -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] BUNNY_API_KEY = "API key" [Configuration.Additional] - BUNNY_POLLING_INTERVAL = "Time between DNS propagation check" - BUNNY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - BUNNY_TTL = "The TTL of the TXT record used for the DNS challenge" + BUNNY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + BUNNY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + BUNNY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + BUNNY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docs.bunny.net/reference/dnszonepublic_index" diff --git a/providers/dns/bunny/bunny_test.go b/providers/dns/bunny/bunny_test.go index e5724bcd2..ca4e821e0 100644 --- a/providers/dns/bunny/bunny_test.go +++ b/providers/dns/bunny/bunny_test.go @@ -4,6 +4,9 @@ import ( "testing" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" + "github.com/nrdcg/bunny-go" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -37,6 +40,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -104,6 +108,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -117,9 +122,124 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } + +func Test_findZone(t *testing.T) { + testCases := []struct { + desc string + domain string + items []*bunny.DNSZone + expected *bunny.DNSZone + }{ + { + desc: "found subdomain", + domain: "_acme-challenge.foo.bar.example.com", + items: []*bunny.DNSZone{ + {ID: ptr.Pointer[int64](1), Domain: ptr.Pointer("example.com")}, + {ID: ptr.Pointer[int64](2), Domain: ptr.Pointer("example.org")}, + {ID: ptr.Pointer[int64](4), Domain: ptr.Pointer("bar.example.org")}, + {ID: ptr.Pointer[int64](5), Domain: ptr.Pointer("bar.example.com")}, + {ID: ptr.Pointer[int64](6), Domain: ptr.Pointer("foo.example.com")}, + }, + expected: &bunny.DNSZone{ + ID: ptr.Pointer[int64](5), + Domain: ptr.Pointer("bar.example.com"), + }, + }, + { + desc: "found the longest subdomain", + domain: "_acme-challenge.foo.bar.example.com", + items: []*bunny.DNSZone{ + {ID: ptr.Pointer[int64](7), Domain: ptr.Pointer("foo.bar.example.com")}, + {ID: ptr.Pointer[int64](1), Domain: ptr.Pointer("example.com")}, + {ID: ptr.Pointer[int64](2), Domain: ptr.Pointer("example.org")}, + {ID: ptr.Pointer[int64](4), Domain: ptr.Pointer("bar.example.org")}, + {ID: ptr.Pointer[int64](5), Domain: ptr.Pointer("bar.example.com")}, + {ID: ptr.Pointer[int64](6), Domain: ptr.Pointer("foo.example.com")}, + }, + expected: &bunny.DNSZone{ + ID: ptr.Pointer[int64](7), + Domain: ptr.Pointer("foo.bar.example.com"), + }, + }, + { + desc: "found apex", + domain: "_acme-challenge.foo.bar.example.com", + items: []*bunny.DNSZone{ + {ID: ptr.Pointer[int64](1), Domain: ptr.Pointer("example.com")}, + {ID: ptr.Pointer[int64](2), Domain: ptr.Pointer("example.org")}, + {ID: ptr.Pointer[int64](4), Domain: ptr.Pointer("bar.example.org")}, + {ID: ptr.Pointer[int64](6), Domain: ptr.Pointer("foo.example.com")}, + }, + expected: &bunny.DNSZone{ + ID: ptr.Pointer[int64](1), + Domain: ptr.Pointer("example.com"), + }, + }, + { + desc: "not found", + domain: "_acme-challenge.foo.bar.example.com", + items: []*bunny.DNSZone{ + {ID: ptr.Pointer[int64](2), Domain: ptr.Pointer("example.org")}, + {ID: ptr.Pointer[int64](4), Domain: ptr.Pointer("bar.example.org")}, + {ID: ptr.Pointer[int64](6), Domain: ptr.Pointer("foo.example.com")}, + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + zones := &bunny.DNSZones{Items: test.items} + + zone := findZone(zones, test.domain) + + assert.Equal(t, test.expected, zone) + }) + } +} + +func Test_possibleDomains(t *testing.T) { + testCases := []struct { + desc string + domain string + expected []string + }{ + { + desc: "apex", + domain: "example.com", + expected: []string{"example.com"}, + }, + { + desc: "CCTLD", + domain: "example.co.uk", + expected: []string{"example.co.uk"}, + }, + { + desc: "long domain", + domain: "_acme-challenge.foo.bar.example.com", + expected: []string{"_acme-challenge.foo.bar.example.com", "foo.bar.example.com", "bar.example.com", "example.com"}, + }, + { + desc: "empty", + domain: "", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + domains := possibleDomains(test.domain) + + assert.Equal(t, test.expected, domains) + }) + } +} diff --git a/providers/dns/checkdomain/checkdomain.go b/providers/dns/checkdomain/checkdomain.go index 4f1e7c137..4bc926ed9 100644 --- a/providers/dns/checkdomain/checkdomain.go +++ b/providers/dns/checkdomain/checkdomain.go @@ -9,9 +9,11 @@ import ( "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/checkdomain/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -27,6 +29,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Endpoint *url.URL @@ -69,6 +73,7 @@ func NewDNSProvider() (*DNSProvider, error) { if err != nil { return nil, fmt.Errorf("checkdomain: invalid %s: %w", EnvEndpoint, err) } + config.Endpoint = endpoint return NewDNSProviderConfig(config) @@ -83,7 +88,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("checkdomain: missing token") } - client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.Token)) + client := internal.NewClient( + clientdebug.Wrap( + internal.OAuthStaticAccessToken(config.HTTPClient, config.Token), + ), + ) if config.Endpoint != nil { client.BaseURL = config.Endpoint diff --git a/providers/dns/checkdomain/checkdomain.toml b/providers/dns/checkdomain/checkdomain.toml index 309b1dfa1..0b93058ba 100644 --- a/providers/dns/checkdomain/checkdomain.toml +++ b/providers/dns/checkdomain/checkdomain.toml @@ -6,7 +6,7 @@ Since = "v3.3.0" Example = ''' CHECKDOMAIN_TOKEN=yoursecrettoken \ -lego --email you@example.com --dns checkdomain -d '*.example.com' -d example.com run +lego --dns checkdomain -d '*.example.com' -d example.com run ''' [Configuration] @@ -14,10 +14,10 @@ lego --email you@example.com --dns checkdomain -d '*.example.com' -d example.com CHECKDOMAIN_TOKEN = "API token" [Configuration.Additional] CHECKDOMAIN_ENDPOINT = "API endpoint URL, defaults to https://api.checkdomain.de" - CHECKDOMAIN_TTL = "The TTL of the TXT record used for the DNS challenge" - CHECKDOMAIN_POLLING_INTERVAL = "Time between DNS propagation check" - CHECKDOMAIN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - CHECKDOMAIN_HTTP_TIMEOUT = "API request timeout, defaults to 30 seconds" + CHECKDOMAIN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + CHECKDOMAIN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 300)" + CHECKDOMAIN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 7)" + CHECKDOMAIN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developer.checkdomain.de/reference/" diff --git a/providers/dns/checkdomain/checkdomain_test.go b/providers/dns/checkdomain/checkdomain_test.go index d9d0b62a6..b2c940f7a 100644 --- a/providers/dns/checkdomain/checkdomain_test.go +++ b/providers/dns/checkdomain/checkdomain_test.go @@ -46,6 +46,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -108,6 +109,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -121,6 +123,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/checkdomain/internal/client.go b/providers/dns/checkdomain/internal/client.go index 74189dee4..68d090755 100644 --- a/providers/dns/checkdomain/internal/client.go +++ b/providers/dns/checkdomain/internal/client.go @@ -36,11 +36,11 @@ const maxInt = int((^uint(0)) >> 1) // Client the Autodns API client. type Client struct { - domainIDMapping map[string]int - domainIDMu sync.Mutex - BaseURL *url.URL httpClient *http.Client + + domainIDMapping map[string]int + domainIDMu sync.Mutex } // NewClient creates a new Client. @@ -63,6 +63,7 @@ func (c *Client) GetDomainIDByName(ctx context.Context, name string) (int, error c.domainIDMu.Lock() id, ok := c.domainIDMapping[name] c.domainIDMu.Unlock() + if ok { return id, nil } @@ -100,6 +101,7 @@ func (c *Client) listDomains(ctx context.Context) ([]*Domain, error) { totalPages := maxInt var domainList []*Domain + for currentPage <= totalPages { q.Set("page", strconv.Itoa(currentPage)) endpoint.RawQuery = q.Encode() @@ -151,6 +153,7 @@ func (c *Client) CheckNameservers(ctx context.Context, domainID int) error { } var found1, found2 bool + for _, item := range info.Nameservers { switch item.Name { case ns1: @@ -229,6 +232,7 @@ func (c *Client) getDomainInfo(ctx context.Context, domainID int) (*DomainRespon } var res DomainResponse + err = c.do(req, &res) if err != nil { return nil, err @@ -242,6 +246,7 @@ func (c *Client) listRecords(ctx context.Context, domainID int, recordType strin q := endpoint.Query() q.Set("limit", strconv.Itoa(maxLimit)) + if recordType != "" { q.Set("type", recordType) } @@ -250,6 +255,7 @@ func (c *Client) listRecords(ctx context.Context, domainID int, recordType strin totalPages := maxInt var recordList []*Record + for currentPage <= totalPages { q.Set("page", strconv.Itoa(currentPage)) endpoint.RawQuery = q.Encode() diff --git a/providers/dns/checkdomain/internal/client_test.go b/providers/dns/checkdomain/internal/client_test.go index 3f6a7e7a7..68e4f1244 100644 --- a/providers/dns/checkdomain/internal/client_test.go +++ b/providers/dns/checkdomain/internal/client_test.go @@ -1,138 +1,66 @@ package internal import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "reflect" "testing" "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) + client.BaseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) - client.BaseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func checkAuthorizationHeader(req *http.Request) error { - val := req.Header.Get("Authorization") - if val != "Bearer secret" { - return fmt.Errorf("invalid header value, got: %s want %s", val, "Bearer secret") - } - return nil + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer secret")) } func TestClient_GetDomainIDByName(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /v1/domains", + servermock.JSONEncode(DomainListingResponse{ + Embedded: EmbeddedDomainList{Domains: []*Domain{ + {ID: 1, Name: "test.com"}, + {ID: 2, Name: "test.org"}, + }}, + })). + Build(t) - mux.HandleFunc("/v1/domains", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - err := checkAuthorizationHeader(req) - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - domainList := DomainListingResponse{ - Embedded: EmbeddedDomainList{Domains: []*Domain{ - {ID: 1, Name: "test.com"}, - {ID: 2, Name: "test.org"}, - }}, - } - - err = json.NewEncoder(rw).Encode(domainList) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - id, err := client.GetDomainIDByName(context.Background(), "test.com") + id, err := client.GetDomainIDByName(t.Context(), "test.com") require.NoError(t, err) assert.Equal(t, 1, id) } func TestClient_CheckNameservers(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /v1/domains/1/nameservers", + servermock.JSONEncode(NameserverResponse{ + Nameservers: []*Nameserver{ + {Name: ns1}, + {Name: ns2}, + // {Name: "ns.fake.de"}, + }, + })). + Build(t) - mux.HandleFunc("/v1/domains/1/nameservers", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - err := checkAuthorizationHeader(req) - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - nsResp := NameserverResponse{ - Nameservers: []*Nameserver{ - {Name: ns1}, - {Name: ns2}, - // {Name: "ns.fake.de"}, - }, - } - - err = json.NewEncoder(rw).Encode(nsResp) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - err := client.CheckNameservers(context.Background(), 1) + err := client.CheckNameservers(t.Context(), 1) require.NoError(t, err) } func TestClient_CreateRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/1/nameservers/records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - err := checkAuthorizationHeader(req) - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - content, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - if string(bytes.TrimSpace(content)) != `{"name":"test.com","value":"value","ttl":300,"priority":0,"type":"TXT"}` { - http.Error(rw, "invalid request body: "+string(content), http.StatusBadRequest) - return - } - }) + client := mockBuilder(). + Route("POST /v1/domains/1/nameservers/records", nil, + servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). + Build(t) record := &Record{ Name: "test.com", @@ -141,121 +69,51 @@ func TestClient_CreateRecord(t *testing.T) { Value: "value", } - err := client.CreateRecord(context.Background(), 1, record) + err := client.CreateRecord(t.Context(), 1, record) require.NoError(t, err) } func TestClient_DeleteTXTRecord(t *testing.T) { - client, mux := setupTest(t) - domainName := "lego.test" recordValue := "test" - records := []*Record{ - { - Name: "_acme-challenge", - Value: recordValue, - Type: "TXT", - }, - { - Name: "_acme-challenge", - Value: recordValue, - Type: "A", - }, - { - Name: "foobar", - Value: recordValue, - Type: "TXT", - }, - } - - expectedRecords := []*Record{ - { - Name: "_acme-challenge", - Value: recordValue, - Type: "A", - }, - { - Name: "foobar", - Value: recordValue, - Type: "TXT", - }, - } - - mux.HandleFunc("/v1/domains/1", func(rw http.ResponseWriter, req *http.Request) { - err := checkAuthorizationHeader(req) - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - resp := DomainResponse{ - ID: 1, - Name: domainName, - } - - err = json.NewEncoder(rw).Encode(resp) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - mux.HandleFunc("/v1/domains/1/nameservers", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - nsResp := NameserverResponse{ - Nameservers: []*Nameserver{{Name: ns1}, {Name: ns2}}, - } - - err := json.NewEncoder(rw).Encode(nsResp) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - mux.HandleFunc("/v1/domains/1/nameservers/records", func(rw http.ResponseWriter, req *http.Request) { - switch req.Method { - case http.MethodGet: - resp := RecordListingResponse{ + client := mockBuilder(). + Route("GET /v1/domains/", + servermock.JSONEncode(DomainResponse{ + ID: 1, + Name: domainName, + })). + Route("GET /v1/domains/1/nameservers", + servermock.JSONEncode(NameserverResponse{ + Nameservers: []*Nameserver{{Name: ns1}, {Name: ns2}}, + })). + Route("GET /v1/domains/1/nameservers/records", + servermock.JSONEncode(RecordListingResponse{ Embedded: EmbeddedRecordList{ - Records: records, + Records: []*Record{ + { + Name: "_acme-challenge", + Value: recordValue, + Type: "TXT", + }, + { + Name: "_acme-challenge", + Value: recordValue, + Type: "A", + }, + { + Name: "foobar", + Value: recordValue, + Type: "TXT", + }, + }, }, - } - - err := json.NewEncoder(rw).Encode(resp) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - case http.MethodPut: - var records []*Record - err := json.NewDecoder(req.Body).Decode(&records) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - if len(records) == 0 { - http.Error(rw, "empty request body", http.StatusBadRequest) - return - } - - if !reflect.DeepEqual(expectedRecords, records) { - http.Error(rw, fmt.Sprintf("invalid records: %v", records), http.StatusBadRequest) - return - } - default: - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - } - }) + })). + Route("PUT /v1/domains/1/nameservers/records", nil, + servermock.CheckRequestJSONBodyFromFixture("delete_txt_record-request.json")). + Build(t) info := dns01.GetChallengeInfo(domainName, "abc") - err := client.DeleteTXTRecord(context.Background(), 1, info.EffectiveFQDN, recordValue) + err := client.DeleteTXTRecord(t.Context(), 1, info.EffectiveFQDN, recordValue) require.NoError(t, err) } diff --git a/providers/dns/checkdomain/internal/fixtures/create_record-request.json b/providers/dns/checkdomain/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..af1d50625 --- /dev/null +++ b/providers/dns/checkdomain/internal/fixtures/create_record-request.json @@ -0,0 +1,7 @@ +{ + "name": "test.com", + "value": "value", + "ttl": 300, + "priority": 0, + "type": "TXT" +} diff --git a/providers/dns/checkdomain/internal/fixtures/delete_txt_record-request.json b/providers/dns/checkdomain/internal/fixtures/delete_txt_record-request.json new file mode 100644 index 000000000..67cb2570c --- /dev/null +++ b/providers/dns/checkdomain/internal/fixtures/delete_txt_record-request.json @@ -0,0 +1,16 @@ +[ + { + "name": "_acme-challenge", + "value": "test", + "ttl": 0, + "priority": 0, + "type": "A" + }, + { + "name": "foobar", + "value": "test", + "ttl": 0, + "priority": 0, + "type": "TXT" + } +] diff --git a/providers/dns/civo/civo.go b/providers/dns/civo/civo.go index 26bdc7995..dfb7c307f 100644 --- a/providers/dns/civo/civo.go +++ b/providers/dns/civo/civo.go @@ -2,19 +2,17 @@ package civo import ( + "context" "errors" "fmt" + "net/http" "time" - "github.com/civo/civogo" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" -) - -const ( - minTTL = 600 - defaultPollingInterval = 30 * time.Second - defaultPropagationTimeout = 300 * time.Second + "github.com/go-acme/lego/v4/providers/dns/civo/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -26,15 +24,25 @@ const ( EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const ( + minTTL = 600 + defaultPollingInterval = 30 * time.Second + defaultPropagationTimeout = 300 * time.Second +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { - ProjectID string - Token string + Token string + PropagationTimeout time.Duration PollingInterval time.Duration TTL int + HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. @@ -43,13 +51,16 @@ func NewDefaultConfig() *Config { TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, defaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config - client *civogo.Client + client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for CIVO. @@ -81,7 +92,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { } // Create a Civo client - DNS is region independent, we can use any region - client, err := civogo.NewClient(config.Token, "LON1") + client, err := internal.NewClient( + clientdebug.Wrap( + internal.OAuthStaticAccessToken(config.HTTPClient, config.Token), + ), + "LON1") if err != nil { return nil, fmt.Errorf("civo: %w", err) } @@ -93,6 +108,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) + ctx := context.Background() + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("civo: could not find zone for domain %q: %w", domain, err) @@ -100,7 +117,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { zone := dns01.UnFqdn(authZone) - dnsDomain, err := d.client.GetDNSDomain(zone) + domainID, err := d.getDomainIDByName(ctx, zone) if err != nil { return fmt.Errorf("civo: %w", err) } @@ -110,10 +127,10 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("civo: %w", err) } - _, err = d.client.CreateDNSRecord(dnsDomain.ID, &civogo.DNSRecordConfig{ + _, err = d.client.CreateDNSRecord(ctx, domainID, internal.Record{ Name: subDomain, Value: info.Value, - Type: civogo.DNSRecordTypeTXT, + Type: "TXT", TTL: d.config.TTL, }) if err != nil { @@ -127,6 +144,8 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) + ctx := context.Background() + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("civo: could not find zone for domain %q: %w", domain, err) @@ -134,12 +153,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { zone := dns01.UnFqdn(authZone) - dnsDomain, err := d.client.GetDNSDomain(zone) + domainID, err := d.getDomainIDByName(ctx, zone) if err != nil { return fmt.Errorf("civo: %w", err) } - dnsRecords, err := d.client.ListDNSRecords(dnsDomain.ID) + dnsRecords, err := d.client.ListDNSRecords(ctx, domainID) if err != nil { return fmt.Errorf("civo: %w", err) } @@ -149,7 +168,8 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("civo: %w", err) } - var dnsRecord civogo.DNSRecord + var dnsRecord internal.Record + for _, entry := range dnsRecords { if entry.Name == subDomain && entry.Value == info.Value { dnsRecord = entry @@ -157,7 +177,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } } - _, err = d.client.DeleteDNSRecord(&dnsRecord) + err = d.client.DeleteDNSRecord(ctx, dnsRecord) if err != nil { return fmt.Errorf("civo: %w", err) } @@ -170,3 +190,18 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } + +func (d *DNSProvider) getDomainIDByName(ctx context.Context, domain string) (string, error) { + domains, err := d.client.ListDomains(ctx) + if err != nil { + return "", fmt.Errorf("list domains: %w", err) + } + + for _, d := range domains { + if d.Name == domain { + return d.ID, nil + } + } + + return "", fmt.Errorf("domain %q not found", domain) +} diff --git a/providers/dns/civo/civo.toml b/providers/dns/civo/civo.toml index fe29364a4..b525712c8 100644 --- a/providers/dns/civo/civo.toml +++ b/providers/dns/civo/civo.toml @@ -6,16 +6,16 @@ Since = "v4.9.0" Example = ''' CIVO_TOKEN=xxxxxx \ -lego --email you@example.com --dns civo -d '*.example.com' -d example.com run +lego --dns civo -d '*.example.com' -d example.com run ''' [Configuration] - [Configuration.Credentials] - CIVO_TOKEN = "Authentication token" - [Configuration.Additional] - CIVO_POLLING_INTERVAL = "Time between DNS propagation check" - CIVO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - CIVO_TTL = "The TTL of the TXT record used for the DNS challenge" + [Configuration.Credentials] + CIVO_TOKEN = "Authentication token" + [Configuration.Additional] + CIVO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)" + CIVO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" + CIVO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" [Links] API = "https://www.civo.com/api/dns" diff --git a/providers/dns/civo/civo_test.go b/providers/dns/civo/civo_test.go index 333cf0b1f..416dbac1d 100644 --- a/providers/dns/civo/civo_test.go +++ b/providers/dns/civo/civo_test.go @@ -2,10 +2,13 @@ package civo import ( "fmt" + "net/http/httptest" + "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -39,6 +42,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -103,6 +107,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -116,6 +121,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -124,3 +130,66 @@ func TestLiveCleanUp(t *testing.T) { err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.HTTPClient = server.Client() + config.Token = "secret" + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With("Authorization", "Bearer secret"). + WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + // https://www.civo.com/api/dns#list-domain-names + Route("GET /dns", + servermock.ResponseFromInternal("list_domain_names.json"), + servermock.CheckQueryParameter().Strict(). + With("region", "LON1")). + // https://www.civo.com/api/dns#create-a-new-dns-record + Route("POST /dns/7088fcea-7658-43e6-97fa-273f901978fd/records", + servermock.ResponseFromInternal("create_dns_record.json"), + servermock.CheckRequestJSONBodyFromInternal("create_dns_record-request.json")). + Build(t) + + err := provider.Present("example.com", "abd", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + // https://www.civo.com/api/dns#list-domain-names + Route("GET /dns", + servermock.ResponseFromInternal("list_domain_names.json"), + servermock.CheckQueryParameter(). + With("region", "LON1")). + // https://www.civo.com/api/dns#list-dns-records + Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records", + servermock.ResponseFromInternal("list_dns_records.json"), + servermock.CheckQueryParameter().Strict(). + With("region", "LON1")). + // https://www.civo.com/api/dns#deleting-a-dns-record + Route("DELETE /dns/edc5dacf-a2ad-4757-41ee-c12f06259c70/records/76cc107f-fbef-4e2b-b97f-f5d34f4075d3", + servermock.ResponseFromInternal("delete_dns_record.json"), + servermock.CheckQueryParameter().Strict(). + With("region", "LON1")). + Build(t) + + err := provider.CleanUp("example.com", "abd", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/civo/internal/client.go b/providers/dns/civo/internal/client.go new file mode 100644 index 000000000..dc1d57793 --- /dev/null +++ b/providers/dns/civo/internal/client.go @@ -0,0 +1,213 @@ +/* +Package internal Civo API client. + +Because the dependencies on k8s, the official client cannot be used. +- https://github.com/civo/civogo/blob/v0.2.99/go.mod -> k8s.io/client-go +- https://github.com/civo/civogo/blob/v0.3.34/go.mod -> k8s.io/api +- https://github.com/civo/civogo/blob/v0.3.38/go.mod -> k8s.io/api + k8s.io/apimachinery +- Current version -> https://github.com/civo/civogo/blob/v0.6.1/go.mod +*/ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" + "golang.org/x/oauth2" +) + +const defaultBaseURL = "https://api.civo.com/v2" + +// Client the Civo API client. +type Client struct { + region string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(hc *http.Client, region string) (*Client, error) { + baseURL, _ := url.Parse(defaultBaseURL) + + if hc == nil { + hc = &http.Client{Timeout: 10 * time.Second} + } + + return &Client{ + region: region, + BaseURL: baseURL, + HTTPClient: hc, + }, nil +} + +// ListDomains a list of all domain names within the account. +// https://www.civo.com/api/dns#list-domain-names +func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { + endpoint := c.BaseURL.JoinPath("dns") + + req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result []Domain + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result, nil +} + +// ListDNSRecords a list of all DNS records in the specified domain. +// https://www.civo.com/api/dns#list-dns-records +func (c *Client) ListDNSRecords(ctx context.Context, domainID string) ([]Record, error) { + endpoint := c.BaseURL.JoinPath("dns", domainID, "records") + + req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result []Record + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result, nil +} + +// CreateDNSRecord creates DNS records for a specific domain. +// https://www.civo.com/api/dns#create-a-new-dns-record +func (c *Client) CreateDNSRecord(ctx context.Context, domainID string, record Record) (*Record, error) { + endpoint := c.BaseURL.JoinPath("dns", domainID, "records") + + req, err := c.newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return nil, err + } + + var result Record + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// DeleteDNSRecord remove a DNS record from a domain. +// https://www.civo.com/api/dns#deleting-a-dns-record +func (c *Client) DeleteDNSRecord(ctx context.Context, record Record) error { + endpoint := c.BaseURL.JoinPath("dns", record.DomainID, "records", record.ID) + + req, err := c.newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) do(req *http.Request, result any) error { + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func (c *Client) newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + if method == http.MethodGet || method == http.MethodDelete { + query := endpoint.Query() + query.Set("region", c.region) + + endpoint.RawQuery = query.Encode() + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + useragent.SetHeader(req.Header) + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} + +// OAuthStaticAccessToken Authorization header. +// https://www.civo.com/api#authentication +func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { + if client == nil { + client = &http.Client{Timeout: 5 * time.Second} + } + + client.Transport = &oauth2.Transport{ + Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), + Base: client.Transport, + } + + return client +} diff --git a/providers/dns/civo/internal/client_test.go b/providers/dns/civo/internal/client_test.go new file mode 100644 index 000000000..ad56b75de --- /dev/null +++ b/providers/dns/civo/internal/client_test.go @@ -0,0 +1,154 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(OAuthStaticAccessToken(server.Client(), "secret"), "LON1") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With("Authorization", "Bearer secret"). + WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`), + ) +} + +func TestClient_ListDomains(t *testing.T) { + client := mockBuilder(). + Route("GET /dns", + servermock.ResponseFromFixture("list_domain_names.json"), + servermock.CheckQueryParameter().Strict(). + With("region", "LON1")). + Build(t) + + domains, err := client.ListDomains(t.Context()) + require.NoError(t, err) + + expected := []Domain{{ + ID: "7088fcea-7658-43e6-97fa-273f901978fd", + AccountID: "e7e8386e-434e-482f-95e0-c406e5d564c2", + Name: "example.com", + }} + + assert.Equal(t, expected, domains) +} + +func TestClient_ListDNSRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records", + servermock.ResponseFromFixture("list_dns_records.json"), + servermock.CheckQueryParameter().Strict(). + With("region", "LON1")). + Build(t) + + records, err := client.ListDNSRecords(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd") + require.NoError(t, err) + + expected := []Record{ + { + ID: "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", + DomainID: "edc5dacf-a2ad-4757-41ee-c12f06259c70", + Name: "_acme-challenge", + Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + Type: "txt", + TTL: 600, + }, + } + + assert.Equal(t, expected, records) +} + +func TestClient_ListDNSRecords_error(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + _, err := client.ListDNSRecords(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd") + require.EqualError(t, err, "database_account_not_found: Failed to find the account within the internal database") +} + +func TestClient_ListDNSRecords_error_raw(t *testing.T) { + // the API says: + // > 4xx/5xx status may not be JSON, unless it's obvious that the response should be parsed for a specific reason. + // > So, for example, 404 Not Found pages are a standard page of text + // > but 403 Unauthorized requests may have a reason attribute available in the JSON object. + // https://www.civo.com/api#parameters-and-responses + client := mockBuilder(). + Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records", + servermock.RawStringResponse(http.StatusText(http.StatusNotFound)). + WithStatusCode(http.StatusNotFound)). + Build(t) + + _, err := client.ListDNSRecords(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd") + require.EqualError(t, err, "unexpected status code: [status code: 404] body: Not Found") +} + +func TestClient_CreateDNSRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/7088fcea-7658-43e6-97fa-273f901978fd/records", + servermock.ResponseFromFixture("create_dns_record.json"), + servermock.CheckRequestJSONBodyFromFixture("create_dns_record-request.json")). + Build(t) + + record := Record{ + Name: "_acme-challenge", + Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + Type: "TXT", + TTL: 600, + } + + newRecord, err := client.CreateDNSRecord(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd", record) + require.NoError(t, err) + + expected := &Record{ + ID: "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", + DomainID: "edc5dacf-a2ad-4757-41ee-c12f06259c70", + Name: "_acme-challenge", + Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + Type: "txt", + TTL: 600, + } + + assert.Equal(t, expected, newRecord) +} + +func TestClient_DeleteDNSRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /dns/edc5dacf-a2ad-4757-41ee-c12f06259c70/records/76cc107f-fbef-4e2b-b97f-f5d34f4075d3", + servermock.ResponseFromFixture("delete_dns_record.json"), + servermock.CheckQueryParameter().Strict(). + With("region", "LON1")). + Build(t) + + record := Record{ + ID: "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", + DomainID: "edc5dacf-a2ad-4757-41ee-c12f06259c70", + Name: "_acme-challenge", + Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + Type: "TXT", + TTL: 600, + } + + err := client.DeleteDNSRecord(t.Context(), record) + require.NoError(t, err) +} diff --git a/providers/dns/civo/internal/fixtures/create_dns_record-request.json b/providers/dns/civo/internal/fixtures/create_dns_record-request.json new file mode 100644 index 000000000..ec881e142 --- /dev/null +++ b/providers/dns/civo/internal/fixtures/create_dns_record-request.json @@ -0,0 +1,6 @@ +{ + "type": "TXT", + "name": "_acme-challenge", + "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 600 +} diff --git a/providers/dns/civo/internal/fixtures/create_dns_record.json b/providers/dns/civo/internal/fixtures/create_dns_record.json new file mode 100644 index 000000000..d9557cf23 --- /dev/null +++ b/providers/dns/civo/internal/fixtures/create_dns_record.json @@ -0,0 +1,11 @@ +{ + "id": "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", + "created_at": "2019-04-11T12:47:56.000+01:00", + "updated_at": "2019-04-11T12:47:56.000+01:00", + "account_id": null, + "domain_id": "edc5dacf-a2ad-4757-41ee-c12f06259c70", + "name": "_acme-challenge", + "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "type": "txt", + "ttl": 600 +} diff --git a/providers/dns/civo/internal/fixtures/delete_dns_record.json b/providers/dns/civo/internal/fixtures/delete_dns_record.json new file mode 100644 index 000000000..80bf76ad5 --- /dev/null +++ b/providers/dns/civo/internal/fixtures/delete_dns_record.json @@ -0,0 +1,3 @@ +{ + "result": "success" +} diff --git a/providers/dns/civo/internal/fixtures/error.json b/providers/dns/civo/internal/fixtures/error.json new file mode 100644 index 000000000..0a55e079f --- /dev/null +++ b/providers/dns/civo/internal/fixtures/error.json @@ -0,0 +1,4 @@ +{ + "code": "database_account_not_found", + "reason": "Failed to find the account within the internal database" +} diff --git a/providers/dns/civo/internal/fixtures/list_dns_records.json b/providers/dns/civo/internal/fixtures/list_dns_records.json new file mode 100644 index 000000000..0c4e54737 --- /dev/null +++ b/providers/dns/civo/internal/fixtures/list_dns_records.json @@ -0,0 +1,13 @@ +[ + { + "id": "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", + "created_at": "2019-04-11T12:47:56.000+01:00", + "updated_at": "2019-04-11T12:47:56.000+01:00", + "account_id": null, + "domain_id": "edc5dacf-a2ad-4757-41ee-c12f06259c70", + "name": "_acme-challenge", + "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "type": "txt", + "ttl": 600 + } +] diff --git a/providers/dns/civo/internal/fixtures/list_domain_names.json b/providers/dns/civo/internal/fixtures/list_domain_names.json new file mode 100644 index 000000000..909cdca04 --- /dev/null +++ b/providers/dns/civo/internal/fixtures/list_domain_names.json @@ -0,0 +1,7 @@ +[ + { + "id": "7088fcea-7658-43e6-97fa-273f901978fd", + "account_id": "e7e8386e-434e-482f-95e0-c406e5d564c2", + "name": "example.com" + } +] diff --git a/providers/dns/civo/internal/types.go b/providers/dns/civo/internal/types.go new file mode 100644 index 000000000..d173e2fcd --- /dev/null +++ b/providers/dns/civo/internal/types.go @@ -0,0 +1,28 @@ +package internal + +import "fmt" + +type APIError struct { + Code string `json:"code"` + Reason string `json:"reason"` +} + +func (a *APIError) Error() string { + return fmt.Sprintf("%s: %s", a.Code, a.Reason) +} + +type Record struct { + ID string `json:"id,omitempty"` + AccountID string `json:"account_id,omitempty"` + DomainID string `json:"domain_id,omitempty"` + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` + Type string `json:"type,omitempty"` + TTL int `json:"ttl,omitempty"` +} + +type Domain struct { + ID string `json:"id,omitempty"` + AccountID string `json:"account_id,omitempty"` + Name string `json:"name,omitempty"` +} diff --git a/providers/dns/clouddns/clouddns.go b/providers/dns/clouddns/clouddns.go index c3b13887e..77b673738 100644 --- a/providers/dns/clouddns/clouddns.go +++ b/providers/dns/clouddns/clouddns.go @@ -8,9 +8,11 @@ import ( "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/clouddns/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -27,6 +29,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the DNSProvider. type Config struct { ClientID string @@ -90,6 +94,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{client: client, config: config}, nil } diff --git a/providers/dns/clouddns/clouddns.toml b/providers/dns/clouddns/clouddns.toml index 1927e21b5..6f516e834 100644 --- a/providers/dns/clouddns/clouddns.toml +++ b/providers/dns/clouddns/clouddns.toml @@ -8,7 +8,7 @@ Example = ''' CLOUDDNS_CLIENT_ID=bLsdFAks23429841238feb177a572aX \ CLOUDDNS_EMAIL=you@example.com \ CLOUDDNS_PASSWORD=b9841238feb177a84330f \ -lego --email you@example.com --dns clouddns -d '*.example.com' -d example.com run +lego --dns clouddns -d '*.example.com' -d example.com run ''' [Configuration] @@ -17,10 +17,10 @@ lego --email you@example.com --dns clouddns -d '*.example.com' -d example.com ru CLOUDDNS_EMAIL = "Account email" CLOUDDNS_PASSWORD = "Account password" [Configuration.Additional] - CLOUDDNS_POLLING_INTERVAL = "Time between DNS propagation check" - CLOUDDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - CLOUDDNS_TTL = "The TTL of the TXT record used for the DNS challenge" - CLOUDDNS_HTTP_TIMEOUT = "API request timeout" + CLOUDDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" + CLOUDDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + CLOUDDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + CLOUDDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://admin.vshosting.cloud/clouddns/swagger/" diff --git a/providers/dns/clouddns/clouddns_test.go b/providers/dns/clouddns/clouddns_test.go index d7bfc4a1f..f1e2a196e 100644 --- a/providers/dns/clouddns/clouddns_test.go +++ b/providers/dns/clouddns/clouddns_test.go @@ -63,6 +63,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -148,6 +149,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -161,6 +163,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/clouddns/internal/client.go b/providers/dns/clouddns/internal/client.go index cd3da50c7..9fb6902de 100644 --- a/providers/dns/clouddns/internal/client.go +++ b/providers/dns/clouddns/internal/client.go @@ -122,6 +122,7 @@ func (c *Client) getDomain(ctx context.Context, zone string) (Domain, error) { } var result SearchResponse + err = c.do(req, &result) if err != nil { return Domain{}, err @@ -143,6 +144,7 @@ func (c *Client) getRecord(ctx context.Context, domainID, recordName string) (Re } var result DomainInfo + err = c.do(req, &result) if err != nil { return Record{}, err @@ -232,6 +234,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response APIError + err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/clouddns/internal/client_test.go b/providers/dns/clouddns/internal/client_test.go index 2a4891cce..a5b780e42 100644 --- a/providers/dns/clouddns/internal/client_test.go +++ b/providers/dns/clouddns/internal/client_test.go @@ -1,130 +1,65 @@ package internal import ( - "context" - "encoding/json" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("clientID", "email@example.com", "secret", 300) + client.HTTPClient = server.Client() + client.apiBaseURL, _ = url.Parse(server.URL + "/api") + client.loginURL, _ = url.Parse(server.URL + "/login") - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("clientID", "email@example.com", "secret", 300) - client.HTTPClient = server.Client() - client.apiBaseURL, _ = url.Parse(server.URL + "/api") - client.loginURL, _ = url.Parse(server.URL + "/login") - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestClient_AddRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("POST /api/domain/search", + servermock.ResponseFromFixture("domain_search.json"), + servermock.CheckRequestJSONBodyFromFixture("domain_search-request.json")). + Route("POST /api/record-txt", nil, + servermock.CheckRequestJSONBodyFromFixture("record_txt-request.json")). + Route("PUT /api/domain/A/publish", nil, + servermock.CheckRequestJSONBodyFromFixture("publish-request.json")). + Route("POST /login", + servermock.ResponseFromFixture("login.json"), + servermock.CheckRequestJSONBodyFromFixture("login-request.json")). + Build(t) - mux.HandleFunc("/api/domain/search", func(rw http.ResponseWriter, req *http.Request) { - response := SearchResponse{ - Items: []Domain{ - { - ID: "A", - DomainName: "example.com", - }, - }, - } + ctx, err := client.CreateAuthenticatedContext(t.Context()) + require.NoError(t, err) - err := json.NewEncoder(rw).Encode(response) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - mux.HandleFunc("/api/record-txt", func(rw http.ResponseWriter, req *http.Request) {}) - mux.HandleFunc("/api/domain/A/publish", func(rw http.ResponseWriter, req *http.Request) {}) - mux.HandleFunc("/login", func(rw http.ResponseWriter, req *http.Request) { - response := AuthResponse{ - Auth: Auth{ - AccessToken: "at", - RefreshToken: "", - }, - } - - err := json.NewEncoder(rw).Encode(response) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - err := client.AddRecord(context.Background(), "example.com", "_acme-challenge.example.com", "txt") + err = client.AddRecord(ctx, "example.com", "_acme-challenge.example.com", "txt") require.NoError(t, err) } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("POST /api/domain/search", + servermock.ResponseFromFixture("domain_search.json"), + servermock.CheckRequestJSONBodyFromFixture("domain_search-request.json")). + Route("GET /api/domain/A", + servermock.ResponseFromFixture("domain-request.json")). + Route("DELETE /api/record/R01", nil). + Route("PUT /api/domain/A/publish", nil, + servermock.CheckRequestJSONBodyFromFixture("publish-request.json")). + Route("POST /login", + servermock.ResponseFromFixture("login.json"), + servermock.CheckRequestJSONBodyFromFixture("login-request.json")). + Build(t) - mux.HandleFunc("/api/domain/search", func(rw http.ResponseWriter, req *http.Request) { - response := SearchResponse{ - Items: []Domain{ - { - ID: "A", - DomainName: "example.com", - }, - }, - } - - err := json.NewEncoder(rw).Encode(response) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - mux.HandleFunc("/api/domain/A", func(rw http.ResponseWriter, req *http.Request) { - response := DomainInfo{ - ID: "Z", - DomainName: "example.com", - LastDomainRecordList: []Record{ - { - ID: "R01", - DomainID: "A", - Name: "_acme-challenge.example.com", - Value: "txt", - Type: "TXT", - }, - }, - SoaTTL: 300, - } - - err := json.NewEncoder(rw).Encode(response) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - mux.HandleFunc("/api/record/R01", func(rw http.ResponseWriter, req *http.Request) {}) - mux.HandleFunc("/api/domain/A/publish", func(rw http.ResponseWriter, req *http.Request) {}) - mux.HandleFunc("/login", func(rw http.ResponseWriter, req *http.Request) { - response := AuthResponse{ - Auth: Auth{ - AccessToken: "at", - RefreshToken: "", - }, - } - - err := json.NewEncoder(rw).Encode(response) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - ctx, err := client.CreateAuthenticatedContext(context.Background()) + ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) err = client.DeleteRecord(ctx, "example.com", "_acme-challenge.example.com") diff --git a/providers/dns/clouddns/internal/fixtures/domain-request.json b/providers/dns/clouddns/internal/fixtures/domain-request.json new file mode 100644 index 000000000..00f60b9bd --- /dev/null +++ b/providers/dns/clouddns/internal/fixtures/domain-request.json @@ -0,0 +1,14 @@ +{ + "id": "Z", + "domainName": "example.com", + "lastDomainRecordList": [ + { + "id": "R01", + "domainId": "A", + "name": "_acme-challenge.example.com", + "value": "txt", + "type": "TXT" + } + ], + "soaTtl": 300 +} diff --git a/providers/dns/clouddns/internal/fixtures/domain_search-request.json b/providers/dns/clouddns/internal/fixtures/domain_search-request.json new file mode 100644 index 000000000..89043dc3a --- /dev/null +++ b/providers/dns/clouddns/internal/fixtures/domain_search-request.json @@ -0,0 +1,14 @@ +{ + "search": [ + { + "name": "clientId", + "operator": "eq", + "value": "clientID" + }, + { + "name": "domainName", + "operator": "eq", + "value": "example.com" + } + ] +} diff --git a/providers/dns/clouddns/internal/fixtures/domain_search.json b/providers/dns/clouddns/internal/fixtures/domain_search.json new file mode 100644 index 000000000..4ee454732 --- /dev/null +++ b/providers/dns/clouddns/internal/fixtures/domain_search.json @@ -0,0 +1,8 @@ +{ + "items": [ + { + "id": "A", + "domainName": "example.com" + } + ] +} diff --git a/providers/dns/clouddns/internal/fixtures/login-request.json b/providers/dns/clouddns/internal/fixtures/login-request.json new file mode 100644 index 000000000..132577e6b --- /dev/null +++ b/providers/dns/clouddns/internal/fixtures/login-request.json @@ -0,0 +1,4 @@ +{ + "email": "email@example.com", + "password": "secret" +} diff --git a/providers/dns/clouddns/internal/fixtures/login.json b/providers/dns/clouddns/internal/fixtures/login.json new file mode 100644 index 000000000..e72ffb19b --- /dev/null +++ b/providers/dns/clouddns/internal/fixtures/login.json @@ -0,0 +1,5 @@ +{ + "auth": { + "accessToken": "at" + } +} diff --git a/providers/dns/clouddns/internal/fixtures/publish-request.json b/providers/dns/clouddns/internal/fixtures/publish-request.json new file mode 100644 index 000000000..383e26958 --- /dev/null +++ b/providers/dns/clouddns/internal/fixtures/publish-request.json @@ -0,0 +1,3 @@ +{ + "soaTtl": 300 +} diff --git a/providers/dns/clouddns/internal/fixtures/record_txt-request.json b/providers/dns/clouddns/internal/fixtures/record_txt-request.json new file mode 100644 index 000000000..cbc2a32a0 --- /dev/null +++ b/providers/dns/clouddns/internal/fixtures/record_txt-request.json @@ -0,0 +1,6 @@ +{ + "domainId": "A", + "name": "_acme-challenge.example.com", + "value": "txt", + "type": "TXT" +} diff --git a/providers/dns/clouddns/internal/identity.go b/providers/dns/clouddns/internal/identity.go index 4ea5c5049..6b20ad814 100644 --- a/providers/dns/clouddns/internal/identity.go +++ b/providers/dns/clouddns/internal/identity.go @@ -20,6 +20,7 @@ func (c *Client) login(ctx context.Context) (*AuthResponse, error) { } var result AuthResponse + err = c.do(req, &result) if err != nil { return nil, err diff --git a/providers/dns/clouddns/internal/identity_test.go b/providers/dns/clouddns/internal/identity_test.go index 3c727448d..267f73335 100644 --- a/providers/dns/clouddns/internal/identity_test.go +++ b/providers/dns/clouddns/internal/identity_test.go @@ -1,41 +1,22 @@ package internal import ( - "context" - "encoding/json" - "net/http" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestClient_CreateAuthenticatedContext(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("POST /login", + servermock.ResponseFromFixture("login.json"), + servermock.CheckRequestJSONBodyFromFixture("login-request.json")). + Route("DELETE /api/record/xxx", nil). + Build(t) - mux.HandleFunc("/login", func(rw http.ResponseWriter, req *http.Request) { - response := AuthResponse{ - Auth: Auth{ - AccessToken: "at", - RefreshToken: "", - }, - } - - err := json.NewEncoder(rw).Encode(response) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - mux.HandleFunc("/api/record/xxx", func(rw http.ResponseWriter, req *http.Request) { - authorization := req.Header.Get(authorizationHeader) - if authorization != "Bearer at" { - http.Error(rw, "invalid credential: "+authorization, http.StatusUnauthorized) - return - } - }) - - ctx, err := client.CreateAuthenticatedContext(context.Background()) + ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) at := getAccessToken(ctx) diff --git a/providers/dns/clouddns/internal/types.go b/providers/dns/clouddns/internal/types.go index a53c958a7..9de11d848 100644 --- a/providers/dns/clouddns/internal/types.go +++ b/providers/dns/clouddns/internal/types.go @@ -21,7 +21,7 @@ type Authorization struct { } type AuthResponse struct { - Auth Auth `json:"auth,omitempty"` + Auth Auth `json:"auth"` } type Auth struct { diff --git a/providers/dns/cloudflare/cloudflare.go b/providers/dns/cloudflare/cloudflare.go index efdbd6e7a..98b3495bb 100644 --- a/providers/dns/cloudflare/cloudflare.go +++ b/providers/dns/cloudflare/cloudflare.go @@ -6,19 +6,48 @@ import ( "errors" "fmt" "net/http" + "strconv" + "strings" "sync" "time" - "github.com/cloudflare/cloudflare-go" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/cloudflare/internal" +) + +// Environment variables names. +const ( + envNamespace = "CLOUDFLARE_" + + EnvEmail = envNamespace + "EMAIL" + EnvAPIKey = envNamespace + "API_KEY" + + EnvDNSAPIToken = envNamespace + "DNS_API_TOKEN" + EnvZoneAPIToken = envNamespace + "ZONE_API_TOKEN" + + EnvBaseURL = envNamespace + "BASE_URL" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +const ( + altEnvNamespace = "CF_" + + altEnvEmail = altEnvNamespace + "API_EMAIL" ) const ( minTTL = 120 ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { AuthEmail string @@ -27,6 +56,8 @@ type Config struct { AuthToken string ZoneToken string + BaseURL string + TTL int PropagationTimeout time.Duration PollingInterval time.Duration @@ -36,11 +67,11 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - TTL: env.GetOrDefaultInt("CLOUDFLARE_TTL", minTTL), - PropagationTimeout: env.GetOrDefaultSecond("CLOUDFLARE_PROPAGATION_TIMEOUT", 2*time.Minute), - PollingInterval: env.GetOrDefaultSecond("CLOUDFLARE_POLLING_INTERVAL", 2*time.Second), + TTL: env.GetOneWithFallback(EnvTTL, minTTL, strconv.Atoi, altEnvName(EnvTTL)), + PropagationTimeout: env.GetOneWithFallback(EnvPropagationTimeout, 2*time.Minute, env.ParseSecond, altEnvName(EnvPropagationTimeout)), + PollingInterval: env.GetOneWithFallback(EnvPollingInterval, dns01.DefaultPollingInterval, env.ParseSecond, altEnvName(EnvPollingInterval)), HTTPClient: &http.Client{ - Timeout: env.GetOrDefaultSecond("CLOUDFLARE_HTTP_TIMEOUT", 30*time.Second), + Timeout: env.GetOneWithFallback(EnvHTTPTimeout, 30*time.Second, env.ParseSecond, altEnvName(EnvHTTPTimeout)), }, } } @@ -68,14 +99,15 @@ type DNSProvider struct { // in this case pass both CLOUDFLARE_ZONE_API_TOKEN and CLOUDFLARE_DNS_API_TOKEN accordingly. func NewDNSProvider() (*DNSProvider, error) { values, err := env.GetWithFallback( - []string{"CLOUDFLARE_EMAIL", "CF_API_EMAIL"}, - []string{"CLOUDFLARE_API_KEY", "CF_API_KEY"}, + []string{EnvEmail, altEnvEmail}, + []string{EnvAPIKey, altEnvName(EnvAPIKey)}, ) if err != nil { var errT error + values, errT = env.GetWithFallback( - []string{"CLOUDFLARE_DNS_API_TOKEN", "CF_DNS_API_TOKEN"}, - []string{"CLOUDFLARE_ZONE_API_TOKEN", "CF_ZONE_API_TOKEN", "CLOUDFLARE_DNS_API_TOKEN", "CF_DNS_API_TOKEN"}, + []string{EnvDNSAPIToken, altEnvName(EnvDNSAPIToken)}, + []string{EnvZoneAPIToken, altEnvName(EnvZoneAPIToken), EnvDNSAPIToken, altEnvName(EnvDNSAPIToken)}, ) if errT != nil { //nolint:errorlint @@ -84,10 +116,11 @@ func NewDNSProvider() (*DNSProvider, error) { } config := NewDefaultConfig() - config.AuthEmail = values["CLOUDFLARE_EMAIL"] - config.AuthKey = values["CLOUDFLARE_API_KEY"] - config.AuthToken = values["CLOUDFLARE_DNS_API_TOKEN"] - config.ZoneToken = values["CLOUDFLARE_ZONE_API_TOKEN"] + config.AuthEmail = values[EnvEmail] + config.AuthKey = values[EnvAPIKey] + config.AuthToken = values[EnvDNSAPIToken] + config.ZoneToken = values[EnvZoneAPIToken] + config.BaseURL = env.GetOrFile(EnvBaseURL) return NewDNSProviderConfig(config) } @@ -122,6 +155,8 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) @@ -129,19 +164,19 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("cloudflare: could not find zone for domain %q: %w", domain, err) } - zoneID, err := d.client.ZoneIDByName(authZone) + zoneID, err := d.client.ZoneIDByName(ctx, authZone) if err != nil { return fmt.Errorf("cloudflare: failed to find zone %s: %w", authZone, err) } - dnsRecord := cloudflare.CreateDNSRecordParams{ + dnsRecord := internal.Record{ Type: "TXT", Name: dns01.UnFqdn(info.EffectiveFQDN), - Content: info.Value, + Content: `"` + info.Value + `"`, TTL: d.config.TTL, } - response, err := d.client.CreateDNSRecord(context.Background(), zoneID, dnsRecord) + response, err := d.client.CreateDNSRecord(ctx, zoneID, dnsRecord) if err != nil { return fmt.Errorf("cloudflare: failed to create TXT record: %w", err) } @@ -157,6 +192,8 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) @@ -164,7 +201,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("cloudflare: could not find zone for domain %q: %w", domain, err) } - zoneID, err := d.client.ZoneIDByName(authZone) + zoneID, err := d.client.ZoneIDByName(ctx, authZone) if err != nil { return fmt.Errorf("cloudflare: failed to find zone %s: %w", authZone, err) } @@ -173,13 +210,14 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("cloudflare: unknown record ID for '%s'", info.EffectiveFQDN) } - err = d.client.DeleteDNSRecord(context.Background(), zoneID, recordID) + err = d.client.DeleteDNSRecord(ctx, zoneID, recordID) if err != nil { - log.Printf("cloudflare: failed to delete TXT record: %w", err) + log.Printf("cloudflare: failed to delete TXT record: %v", err) } // Delete record ID from map @@ -189,3 +227,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } + +func altEnvName(v string) string { + return strings.ReplaceAll(v, envNamespace, altEnvNamespace) +} diff --git a/providers/dns/cloudflare/cloudflare.toml b/providers/dns/cloudflare/cloudflare.toml index 0a8295f69..c46130fe6 100644 --- a/providers/dns/cloudflare/cloudflare.toml +++ b/providers/dns/cloudflare/cloudflare.toml @@ -7,12 +7,12 @@ Since = "v0.3.0" Example = ''' CLOUDFLARE_EMAIL=you@example.com \ CLOUDFLARE_API_KEY=b9841238feb177a84330febba8a83208921177bffe733 \ -lego --email you@example.com --dns cloudflare -d '*.example.com' -d example.com run +lego --dns cloudflare -d '*.example.com' -d example.com run # or CLOUDFLARE_DNS_API_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \ -lego --email you@example.com --dns cloudflare -d '*.example.com' -d example.com run +lego --dns cloudflare -d '*.example.com' -d example.com run ''' Additional = ''' @@ -69,10 +69,11 @@ It follows the principle of least privilege and limits the possible damage, shou CLOUDFLARE_DNS_API_TOKEN = "Alias to CF_DNS_API_TOKEN" CLOUDFLARE_ZONE_API_TOKEN = "Alias to CF_ZONE_API_TOKEN" [Configuration.Additional] - CLOUDFLARE_POLLING_INTERVAL = "Time between DNS propagation check (in seconds)" - CLOUDFLARE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation (in seconds)" - CLOUDFLARE_TTL = "The TTL of the TXT record used for the DNS challenge (in seconds)" - CLOUDFLARE_HTTP_TIMEOUT = "API request timeout (in seconds)" + CLOUDFLARE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + CLOUDFLARE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + CLOUDFLARE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + CLOUDFLARE_HTTP_TIMEOUT = "API request timeout in seconds (Default: )" + CLOUDFLARE_BASE_URL = "API base URL (Default: https://api.cloudflare.com/client/v4)" [Links] API = "https://api.cloudflare.com/" diff --git a/providers/dns/cloudflare/cloudflare_test.go b/providers/dns/cloudflare/cloudflare_test.go index 0aed51254..8de9dd848 100644 --- a/providers/dns/cloudflare/cloudflare_test.go +++ b/providers/dns/cloudflare/cloudflare_test.go @@ -1,20 +1,29 @@ package cloudflare import ( + "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +const envDomain = envNamespace + "DOMAIN" + var envTest = tester.NewEnvTest( - "CLOUDFLARE_EMAIL", - "CLOUDFLARE_API_KEY", - "CLOUDFLARE_DNS_API_TOKEN", - "CLOUDFLARE_ZONE_API_TOKEN"). - WithDomain("CLOUDFLARE_DOMAIN") + EnvEmail, + EnvAPIKey, + EnvDNSAPIToken, + EnvZoneAPIToken, + EnvBaseURL, + altEnvEmail, + altEnvName(EnvAPIKey), + altEnvName(EnvDNSAPIToken), + altEnvName(EnvZoneAPIToken)). + WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { @@ -25,45 +34,45 @@ func TestNewDNSProvider(t *testing.T) { { desc: "success email, API key", envVars: map[string]string{ - "CLOUDFLARE_EMAIL": "test@example.com", - "CLOUDFLARE_API_KEY": "123", + EnvEmail: "test@example.com", + EnvAPIKey: "123", }, }, { desc: "success API token", envVars: map[string]string{ - "CLOUDFLARE_DNS_API_TOKEN": "012345abcdef", + EnvDNSAPIToken: "012345abcdef", }, }, { desc: "success separate API tokens", envVars: map[string]string{ - "CLOUDFLARE_DNS_API_TOKEN": "012345abcdef", - "CLOUDFLARE_ZONE_API_TOKEN": "abcdef012345", + EnvDNSAPIToken: "012345abcdef", + EnvZoneAPIToken: "abcdef012345", }, }, { desc: "missing credentials", envVars: map[string]string{ - "CLOUDFLARE_EMAIL": "", - "CLOUDFLARE_API_KEY": "", - "CLOUDFLARE_DNS_API_TOKEN": "", + EnvEmail: "", + EnvAPIKey: "", + EnvDNSAPIToken: "", }, expected: "cloudflare: some credentials information are missing: CLOUDFLARE_EMAIL,CLOUDFLARE_API_KEY or some credentials information are missing: CLOUDFLARE_DNS_API_TOKEN,CLOUDFLARE_ZONE_API_TOKEN", }, { desc: "missing email", envVars: map[string]string{ - "CLOUDFLARE_EMAIL": "", - "CLOUDFLARE_API_KEY": "key", + EnvEmail: "", + EnvAPIKey: "key", }, expected: "cloudflare: some credentials information are missing: CLOUDFLARE_EMAIL or some credentials information are missing: CLOUDFLARE_DNS_API_TOKEN,CLOUDFLARE_ZONE_API_TOKEN", }, { desc: "missing api key", envVars: map[string]string{ - "CLOUDFLARE_EMAIL": "awesome@possum.com", - "CLOUDFLARE_API_KEY": "", + EnvEmail: "awesome@possum.com", + EnvAPIKey: "", }, expected: "cloudflare: some credentials information are missing: CLOUDFLARE_API_KEY or some credentials information are missing: CLOUDFLARE_DNS_API_TOKEN,CLOUDFLARE_ZONE_API_TOKEN", }, @@ -72,6 +81,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -110,7 +120,7 @@ func TestNewDNSProviderWithToken(t *testing.T) { { desc: "same client when zone token is missing", envVars: map[string]string{ - "CLOUDFLARE_DNS_API_TOKEN": "123", + EnvDNSAPIToken: "123", }, expected: expected{ dnsToken: "123", @@ -121,8 +131,8 @@ func TestNewDNSProviderWithToken(t *testing.T) { { desc: "same client when zone token equals dns token", envVars: map[string]string{ - "CLOUDFLARE_DNS_API_TOKEN": "123", - "CLOUDFLARE_ZONE_API_TOKEN": "123", + EnvDNSAPIToken: "123", + EnvZoneAPIToken: "123", }, expected: expected{ dnsToken: "123", @@ -133,7 +143,7 @@ func TestNewDNSProviderWithToken(t *testing.T) { { desc: "failure when only zone api given", envVars: map[string]string{ - "CLOUDFLARE_ZONE_API_TOKEN": "123", + EnvZoneAPIToken: "123", }, expected: expected{ error: "cloudflare: some credentials information are missing: CLOUDFLARE_EMAIL,CLOUDFLARE_API_KEY or some credentials information are missing: CLOUDFLARE_DNS_API_TOKEN", @@ -142,8 +152,8 @@ func TestNewDNSProviderWithToken(t *testing.T) { { desc: "different clients when zone and dns token differ", envVars: map[string]string{ - "CLOUDFLARE_DNS_API_TOKEN": "123", - "CLOUDFLARE_ZONE_API_TOKEN": "abc", + EnvDNSAPIToken: "123", + EnvZoneAPIToken: "abc", }, expected: expected{ dnsToken: "123", @@ -154,10 +164,10 @@ func TestNewDNSProviderWithToken(t *testing.T) { { desc: "aliases work as expected", // CLOUDFLARE_* takes precedence over CF_* envVars: map[string]string{ - "CLOUDFLARE_DNS_API_TOKEN": "123", - "CF_DNS_API_TOKEN": "456", - "CLOUDFLARE_ZONE_API_TOKEN": "abc", - "CF_ZONE_API_TOKEN": "def", + EnvDNSAPIToken: "123", + altEnvName(EnvDNSAPIToken): "456", + EnvZoneAPIToken: "abc", + altEnvName(EnvZoneAPIToken): "def", }, expected: expected{ dnsToken: "123", @@ -168,15 +178,18 @@ func TestNewDNSProviderWithToken(t *testing.T) { } defer envTest.RestoreEnv() + localEnvTest := tester.NewEnvTest( - "CLOUDFLARE_DNS_API_TOKEN", "CF_DNS_API_TOKEN", - "CLOUDFLARE_ZONE_API_TOKEN", "CF_ZONE_API_TOKEN", - ).WithDomain("CLOUDFLARE_DOMAIN") + EnvDNSAPIToken, altEnvName(EnvDNSAPIToken), + EnvZoneAPIToken, altEnvName(EnvZoneAPIToken), + ).WithDomain(envDomain) + envTest.ClearEnv() for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer localEnvTest.RestoreEnv() + localEnvTest.ClearEnv() localEnvTest.Apply(test.envVars) @@ -191,6 +204,7 @@ func TestNewDNSProviderWithToken(t *testing.T) { require.NotNil(t, p) assert.Equal(t, test.expected.dnsToken, p.config.AuthToken) assert.Equal(t, test.expected.zoneToken, p.config.ZoneToken) + if test.expected.sameClient { assert.Equal(t, p.client.clientRead, p.client.clientEdit) } else { @@ -225,22 +239,17 @@ func TestNewDNSProviderConfig(t *testing.T) { }, { desc: "missing credentials", - expected: "cloudflare: invalid credentials: key & email must not be empty", + expected: "cloudflare: invalid credentials: authEmail, authKey or authToken must be set", }, { desc: "missing email", authKey: "123", - expected: "cloudflare: invalid credentials: key & email must not be empty", + expected: "cloudflare: invalid credentials: authEmail and authKey must be set together", }, { desc: "missing api key", authEmail: "test@example.com", - expected: "cloudflare: invalid credentials: key & email must not be empty", - }, - { - desc: "missing api token, fallback to api key/email", - authToken: "", - expected: "cloudflare: invalid credentials: key & email must not be empty", + expected: "cloudflare: invalid credentials: authEmail and authKey must be set together", }, } @@ -271,6 +280,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -284,6 +294,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -292,3 +303,64 @@ func TestLiveCleanUp(t *testing.T) { err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.AuthEmail = "foo@example.com" + config.AuthKey = "secret" + config.BaseURL = server.URL + config.HTTPClient = server.Client() + + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader(). + WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`). + With("X-Auth-Email", "foo@example.com"). + With("X-Auth-Key", "secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + // https://developers.cloudflare.com/api/resources/zones/methods/list/ + Route("GET /zones", + servermock.ResponseFromInternal("zones.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com"). + With("per_page", "50")). + // https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/ + Route("POST /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records", + servermock.ResponseFromInternal("create_record.json"), + servermock.CheckHeader(). + WithContentType("application/json"), + servermock.CheckRequestJSONBodyFromInternal("create_record-request.json")). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + // https://developers.cloudflare.com/api/resources/zones/methods/list/ + Route("GET /zones", + servermock.ResponseFromInternal("zones.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com"). + With("per_page", "50")). + // https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete/ + Route("DELETE /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records/xxx", + servermock.ResponseFromInternal("delete_record.json")). + Build(t) + + token := "abc" + + provider.recordIDsMu.Lock() + provider.recordIDs["abc"] = "xxx" + provider.recordIDsMu.Unlock() + + err := provider.CleanUp("example.com", token, "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/cloudflare/internal/client.go b/providers/dns/cloudflare/internal/client.go new file mode 100644 index 000000000..b63612ce2 --- /dev/null +++ b/providers/dns/cloudflare/internal/client.go @@ -0,0 +1,202 @@ +/* +Package internal Cloudflare API client. + +The official client is huge and still growing. +- https://github.com/cloudflare/cloudflare-go/issues/4171 +*/ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://api.cloudflare.com/client/v4" + +// Client the Cloudflare API client. +type Client struct { + authEmail string + authKey string + authToken string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(opts ...Option) (*Client, error) { + baseURL, _ := url.Parse(defaultBaseURL) + + client := &Client{ + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + } + + for _, opt := range opts { + err := opt(client) + if err != nil { + return nil, err + } + } + + if client.authToken != "" { + return client, nil + } + + if client.authEmail == "" && client.authKey == "" { + return nil, errors.New("invalid credentials: authEmail, authKey or authToken must be set") + } + + if client.authEmail == "" || client.authKey == "" { + return nil, errors.New("invalid credentials: authEmail and authKey must be set together") + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return client, nil +} + +// CreateDNSRecord creates a new DNS record for a zone. +// https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/ +func (c *Client) CreateDNSRecord(ctx context.Context, zoneID string, record Record) (*Record, error) { + endpoint := c.baseURL.JoinPath("zones", zoneID, "dns_records") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return nil, err + } + + var result APIResponse[Record] + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return &result.Result, nil +} + +// DeleteDNSRecord deletes DNS record. +// https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete/ +func (c *Client) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error { + endpoint := c.baseURL.JoinPath("zones", zoneID, "dns_records", recordID) + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + return c.do(req, nil) +} + +// ZonesByName returns a list of zones matching the given name. +// https://developers.cloudflare.com/api/resources/zones/methods/list/ +func (c *Client) ZonesByName(ctx context.Context, name string) ([]Zone, error) { + endpoint := c.baseURL.JoinPath("zones") + + query := endpoint.Query() + query.Set("name", name) + query.Set("per_page", "50") + endpoint.RawQuery = query.Encode() + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result APIResponse[[]Zone] + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result.Result, nil +} + +func (c *Client) do(req *http.Request, result any) error { + // https://developers.cloudflare.com/fundamentals/api/how-to/make-api-calls/ + if c.authToken != "" { + req.Header.Set("Authorization", "Bearer "+c.authToken) + } else { + req.Header.Set("X-Auth-Email", c.authEmail) + req.Header.Set("X-Auth-Key", c.authKey) + } + + useragent.SetHeader(req.Header) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var response APIResponse[any] + + err := json.Unmarshal(raw, &response) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return fmt.Errorf("[status code %d] %w", resp.StatusCode, response.Errors) +} diff --git a/providers/dns/cloudflare/internal/client_test.go b/providers/dns/cloudflare/internal/client_test.go new file mode 100644 index 000000000..9d286016f --- /dev/null +++ b/providers/dns/cloudflare/internal/client_test.go @@ -0,0 +1,176 @@ +package internal + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient( + WithAuthKey("foo@example.com", "secret"), + WithHTTPClient(server.Client()), + WithBaseURL(server.URL), + ) + if err != nil { + return nil, err + } + + return client, nil + }, + servermock.CheckHeader(). + WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`). + WithAccept("application/json"). + With("X-Auth-Email", "foo@example.com"). + With("X-Auth-Key", "secret"), + ) +} + +func TestClient_CreateDNSRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records", + servermock.ResponseFromFixture("create_record.json"), + servermock.CheckHeader(). + WithContentType("application/json"), + servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). + Build(t) + + record := Record{ + Name: "_acme-challenge.example.com", + TTL: 120, + Type: "TXT", + Content: `"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"`, + } + + newRecord, err := client.CreateDNSRecord(t.Context(), "023e105f4ecef8ad9ca31a8372d0c353", record) + require.NoError(t, err) + + expected := &Record{ + ID: "023e105f4ecef8ad9ca31a8372d0c353", + Name: "example.com", + TTL: 3600, + Type: "A", + Comment: "Domain verification record", + Content: "198.51.100.4", + } + + assert.Equal(t, expected, newRecord) +} + +func TestClient_CreateDNSRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + record := Record{ + Name: "_acme-challenge.example.com", + TTL: 120, + Type: "TXT", + Content: `"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"`, + } + + _, err := client.CreateDNSRecord(t.Context(), "023e105f4ecef8ad9ca31a8372d0c353", record) + require.EqualError(t, err, "[status code 400] 6003: Invalid request headers; 6103: Invalid format for X-Auth-Key header") +} + +func TestClient_DeleteDNSRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records/xxx", + servermock.ResponseFromFixture("delete_record.json")). + Build(t) + + err := client.DeleteDNSRecord(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "xxx") + require.NoError(t, err) +} + +func TestClient_DeleteDNSRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records/xxx", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + err := client.DeleteDNSRecord(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "xxx") + require.EqualError(t, err, "[status code 400] 6003: Invalid request headers; 6103: Invalid format for X-Auth-Key header") +} + +func TestClient_ZonesByName(t *testing.T) { + client := mockBuilder(). + Route("GET /zones", + servermock.ResponseFromFixture("zones.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com"). + With("per_page", "50")). + Build(t) + + zones, err := client.ZonesByName(context.Background(), "example.com") + require.NoError(t, err) + + expected := []Zone{ + { + ID: "023e105f4ecef8ad9ca31a8372d0c353", + Account: Account{ID: "023e105f4ecef8ad9ca31a8372d0c353", Name: "Example Account Name"}, + Meta: Meta{ + CdnOnly: true, + CustomCertificateQuota: 1, + DNSOnly: true, + FoundationDNS: true, + PageRuleQuota: 100, + PhishingDetected: false, + Step: 2, + }, + Name: "example.com", + Owner: Owner{ + ID: "023e105f4ecef8ad9ca31a8372d0c353", + Name: "Example Org", + Type: "organization", + }, + Plan: Plan{ + ID: "023e105f4ecef8ad9ca31a8372d0c353", + CanSubscribe: false, + Currency: "USD", + ExternallyManaged: false, + Frequency: "monthly", + IsSubscribed: false, + LegacyDiscount: false, + LegacyID: "free", + Price: 10, + Name: "Example Org", + }, + CnameSuffix: "cdn.cloudflare.com", + Paused: true, + Permissions: []string{"#worker:read"}, + Tenant: Tenant{ + ID: "023e105f4ecef8ad9ca31a8372d0c353", + Name: "Example Account Name", + }, + TenantUnit: TenantUnit{ + ID: "023e105f4ecef8ad9ca31a8372d0c353", + }, + Type: "full", + VanityNameServers: []string{"ns1.example.com", "ns2.example.com"}, + }, + } + + assert.Equal(t, expected, zones) +} + +func TestClient_ZonesByName_error(t *testing.T) { + client := mockBuilder(). + Route("GET /zones", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + _, err := client.ZonesByName(context.Background(), "example.com") + require.EqualError(t, err, "[status code 400] 6003: Invalid request headers; 6103: Invalid format for X-Auth-Key header") +} diff --git a/providers/dns/cloudflare/internal/fixtures/create_record-request.json b/providers/dns/cloudflare/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..1b8604dc9 --- /dev/null +++ b/providers/dns/cloudflare/internal/fixtures/create_record-request.json @@ -0,0 +1,6 @@ +{ + "type": "TXT", + "name": "_acme-challenge.example.com", + "content": "\"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY\"", + "ttl": 120 +} diff --git a/providers/dns/cloudflare/internal/fixtures/create_record.json b/providers/dns/cloudflare/internal/fixtures/create_record.json new file mode 100644 index 000000000..7e08e993b --- /dev/null +++ b/providers/dns/cloudflare/internal/fixtures/create_record.json @@ -0,0 +1,40 @@ +{ + "errors": [ + { + "code": 1000, + "message": "message", + "documentation_url": "documentation_url", + "source": { + "pointer": "pointer" + } + } + ], + "messages": [ + { + "code": 1000, + "message": "message", + "documentation_url": "documentation_url", + "source": { + "pointer": "pointer" + } + } + ], + "success": true, + "result": { + "name": "example.com", + "ttl": 3600, + "type": "A", + "comment": "Domain verification record", + "content": "198.51.100.4", + "proxied": true, + "settings": { + "ipv4_only": true, + "ipv6_only": true + }, + "tags": [ + "owner:dns-team" + ], + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "proxiable": true + } +} diff --git a/providers/dns/cloudflare/internal/fixtures/delete_record.json b/providers/dns/cloudflare/internal/fixtures/delete_record.json new file mode 100644 index 000000000..038ac7b23 --- /dev/null +++ b/providers/dns/cloudflare/internal/fixtures/delete_record.json @@ -0,0 +1,5 @@ +{ + "result": { + "id": "023e105f4ecef8ad9ca31a8372d0c353" + } +} diff --git a/providers/dns/cloudflare/internal/fixtures/error.json b/providers/dns/cloudflare/internal/fixtures/error.json new file mode 100644 index 000000000..1b2360cc4 --- /dev/null +++ b/providers/dns/cloudflare/internal/fixtures/error.json @@ -0,0 +1,17 @@ +{ + "success": false, + "errors": [ + { + "code": 6003, + "message": "Invalid request headers", + "error_chain": [ + { + "code": 6103, + "message": "Invalid format for X-Auth-Key header" + } + ] + } + ], + "messages": [], + "result": null +} diff --git a/providers/dns/cloudflare/internal/fixtures/zones.json b/providers/dns/cloudflare/internal/fixtures/zones.json new file mode 100644 index 000000000..1dd94c4e3 --- /dev/null +++ b/providers/dns/cloudflare/internal/fixtures/zones.json @@ -0,0 +1,83 @@ +{ + "errors": [ + { + "code": 1000, + "message": "message", + "documentation_url": "documentation_url", + "source": { + "pointer": "pointer" + } + } + ], + "messages": [ + { + "code": 1000, + "message": "message", + "documentation_url": "documentation_url", + "source": { + "pointer": "pointer" + } + } + ], + "success": true, + "result": [ + { + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "account": { + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "name": "Example Account Name" + }, + "meta": { + "cdn_only": true, + "custom_certificate_quota": 1, + "dns_only": true, + "foundation_dns": true, + "page_rule_quota": 100, + "phishing_detected": false, + "step": 2 + }, + "name": "example.com", + "owner": { + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "name": "Example Org", + "type": "organization" + }, + "plan": { + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "can_subscribe": false, + "currency": "USD", + "externally_managed": false, + "frequency": "monthly", + "is_subscribed": false, + "legacy_discount": false, + "legacy_id": "free", + "price": 10, + "name": "Example Org" + }, + "cname_suffix": "cdn.cloudflare.com", + "paused": true, + "permissions": [ + "#worker:read" + ], + "tenant": { + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "name": "Example Account Name" + }, + "tenant_unit": { + "id": "023e105f4ecef8ad9ca31a8372d0c353" + }, + "type": "full", + "vanity_name_servers": [ + "ns1.example.com", + "ns2.example.com" + ] + } + ], + "result_info": { + "count": 1, + "page": 1, + "per_page": 20, + "total_count": 1, + "total_pages": 1 + } +} diff --git a/providers/dns/cloudflare/internal/options.go b/providers/dns/cloudflare/internal/options.go new file mode 100644 index 000000000..aa551a422 --- /dev/null +++ b/providers/dns/cloudflare/internal/options.go @@ -0,0 +1,52 @@ +package internal + +import ( + "net/http" + "net/url" +) + +type Option func(c *Client) error + +func WithAuthKey(authEmail, authKey string) Option { + return func(c *Client) error { + c.authEmail = authEmail + c.authKey = authKey + + return nil + } +} + +func WithAuthToken(authToken string) Option { + return func(c *Client) error { + c.authToken = authToken + + return nil + } +} + +func WithBaseURL(baseURL string) Option { + return func(c *Client) error { + if baseURL == "" { + return nil + } + + bu, err := url.Parse(baseURL) + if err != nil { + return err + } + + c.baseURL = bu + + return nil + } +} + +func WithHTTPClient(client *http.Client) Option { + return func(c *Client) error { + if client != nil { + c.HTTPClient = client + } + + return nil + } +} diff --git a/providers/dns/cloudflare/internal/types.go b/providers/dns/cloudflare/internal/types.go new file mode 100644 index 000000000..50a7bbbf9 --- /dev/null +++ b/providers/dns/cloudflare/internal/types.go @@ -0,0 +1,123 @@ +package internal + +import ( + "fmt" + "strings" +) + +type Record struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + TTL int `json:"ttl,omitempty"` + Type string `json:"type,omitempty"` + Comment string `json:"comment,omitempty"` + Content string `json:"content,omitempty"` +} + +type APIResponse[T any] struct { + Errors Errors `json:"errors,omitempty"` + Messages []Message `json:"messages,omitempty"` + Success bool `json:"success,omitempty"` + Result T `json:"result,omitempty"` + ResultInfo *ResultInfo `json:"result_info,omitempty"` +} + +type Message struct { + Code int `json:"code"` + Message string `json:"message"` + DocumentationURL string `json:"documentation_url"` + Source *Source `json:"source"` + ErrorChain []ErrorChain `json:"error_chain"` +} + +type Source struct { + Pointer string `json:"pointer"` +} + +type ErrorChain struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type Errors []Message + +func (e Errors) Error() string { + msg := new(strings.Builder) + + for _, item := range e { + _, _ = fmt.Fprintf(msg, "%d: %s", item.Code, item.Message) + + for _, link := range item.ErrorChain { + _, _ = fmt.Fprintf(msg, "; %d: %s", link.Code, link.Message) + } + } + + return msg.String() +} + +type ResultInfo struct { + Count int `json:"count"` + Page int `json:"page"` + PerPage int `json:"per_page"` + TotalCount int `json:"total_count"` + TotalPages int `json:"total_pages"` +} + +type Zone struct { + ID string `json:"id"` + Account Account `json:"account"` + Meta Meta `json:"meta"` + Name string `json:"name"` + Owner Owner `json:"owner"` + Plan Plan `json:"plan"` + CnameSuffix string `json:"cname_suffix"` + Paused bool `json:"paused"` + Permissions []string `json:"permissions"` + Tenant Tenant `json:"tenant"` + TenantUnit TenantUnit `json:"tenant_unit"` + Type string `json:"type"` + VanityNameServers []string `json:"vanity_name_servers"` +} + +type Account struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Meta struct { + CdnOnly bool `json:"cdn_only"` + CustomCertificateQuota int `json:"custom_certificate_quota"` + DNSOnly bool `json:"dns_only"` + FoundationDNS bool `json:"foundation_dns"` + PageRuleQuota int `json:"page_rule_quota"` + PhishingDetected bool `json:"phishing_detected"` + Step int `json:"step"` +} + +type Owner struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + +type Plan struct { + ID string `json:"id"` + CanSubscribe bool `json:"can_subscribe"` + Currency string `json:"currency"` + ExternallyManaged bool `json:"externally_managed"` + Frequency string `json:"frequency"` + IsSubscribed bool `json:"is_subscribed"` + LegacyDiscount bool `json:"legacy_discount"` + LegacyID string `json:"legacy_id"` + Price int `json:"price"` + Name string `json:"name"` +} + +type Tenant struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type TenantUnit struct { + ID string `json:"id"` +} diff --git a/providers/dns/cloudflare/wrapper.go b/providers/dns/cloudflare/wrapper.go index a93feeded..286c20ecd 100644 --- a/providers/dns/cloudflare/wrapper.go +++ b/providers/dns/cloudflare/wrapper.go @@ -2,15 +2,16 @@ package cloudflare import ( "context" + "errors" "sync" - "github.com/cloudflare/cloudflare-go" "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/providers/dns/cloudflare/internal" ) type metaClient struct { - clientEdit *cloudflare.API // needs Zone/DNS/Edit permissions - clientRead *cloudflare.API // needs Zone/Zone/Read permissions + clientEdit *internal.Client // needs Zone/DNS/Edit permissions + clientRead *internal.Client // needs Zone/Zone/Read permissions zones map[string]string // caches calls to ZoneIDByName, see lookupZoneID() zonesMu *sync.RWMutex @@ -19,7 +20,10 @@ type metaClient struct { func newClient(config *Config) (*metaClient, error) { // with AuthKey/AuthEmail we can access all available APIs if config.AuthToken == "" { - client, err := cloudflare.New(config.AuthKey, config.AuthEmail, cloudflare.HTTPClient(config.HTTPClient)) + client, err := internal.NewClient( + internal.WithBaseURL(config.BaseURL), + internal.WithHTTPClient(config.HTTPClient), + internal.WithAuthKey(config.AuthEmail, config.AuthKey)) if err != nil { return nil, err } @@ -32,7 +36,10 @@ func newClient(config *Config) (*metaClient, error) { }, nil } - dns, err := cloudflare.NewWithAPIToken(config.AuthToken, cloudflare.HTTPClient(config.HTTPClient)) + dns, err := internal.NewClient( + internal.WithBaseURL(config.BaseURL), + internal.WithHTTPClient(config.HTTPClient), + internal.WithAuthToken(config.AuthToken)) if err != nil { return nil, err } @@ -46,7 +53,10 @@ func newClient(config *Config) (*metaClient, error) { }, nil } - zone, err := cloudflare.NewWithAPIToken(config.ZoneToken, cloudflare.HTTPClient(config.HTTPClient)) + zone, err := internal.NewClient( + internal.WithBaseURL(config.BaseURL), + internal.WithHTTPClient(config.HTTPClient), + internal.WithAuthToken(config.ZoneToken)) if err != nil { return nil, err } @@ -59,19 +69,15 @@ func newClient(config *Config) (*metaClient, error) { }, nil } -func (m *metaClient) CreateDNSRecord(ctx context.Context, zoneID string, rr cloudflare.CreateDNSRecordParams) (cloudflare.DNSRecord, error) { - return m.clientEdit.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(zoneID), rr) -} - -func (m *metaClient) DNSRecords(ctx context.Context, zoneID string, rr cloudflare.ListDNSRecordsParams) ([]cloudflare.DNSRecord, *cloudflare.ResultInfo, error) { - return m.clientEdit.ListDNSRecords(ctx, cloudflare.ZoneIdentifier(zoneID), rr) +func (m *metaClient) CreateDNSRecord(ctx context.Context, zoneID string, rr internal.Record) (*internal.Record, error) { + return m.clientEdit.CreateDNSRecord(ctx, zoneID, rr) } func (m *metaClient) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error { - return m.clientEdit.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(zoneID), recordID) + return m.clientEdit.DeleteDNSRecord(ctx, zoneID, recordID) } -func (m *metaClient) ZoneIDByName(fdqn string) (string, error) { +func (m *metaClient) ZoneIDByName(ctx context.Context, fdqn string) (string, error) { m.zonesMu.RLock() id := m.zones[fdqn] m.zonesMu.RUnlock() @@ -80,7 +86,12 @@ func (m *metaClient) ZoneIDByName(fdqn string) (string, error) { return id, nil } - id, err := m.clientRead.ZoneIDByName(dns01.UnFqdn(fdqn)) + zones, err := m.clientRead.ZonesByName(ctx, dns01.UnFqdn(fdqn)) + if err != nil { + return "", err + } + + id, err = extractZoneID(zones) if err != nil { return "", err } @@ -88,5 +99,17 @@ func (m *metaClient) ZoneIDByName(fdqn string) (string, error) { m.zonesMu.Lock() m.zones[fdqn] = id m.zonesMu.Unlock() + return id, nil } + +func extractZoneID(res []internal.Zone) (string, error) { + switch len(res) { + case 0: + return "", errors.New("zone could not be found") + case 1: + return res[0].ID, nil + default: + return "", errors.New("ambiguous zone name; an account ID might help") + } +} diff --git a/providers/dns/cloudns/cloudns.go b/providers/dns/cloudns/cloudns.go index 554e54163..916d73bde 100644 --- a/providers/dns/cloudns/cloudns.go +++ b/providers/dns/cloudns/cloudns.go @@ -8,11 +8,14 @@ import ( "net/http" "time" + "github.com/cenkalti/backoff/v5" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/cloudns/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -29,6 +32,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { AuthID string @@ -63,6 +68,7 @@ type DNSProvider struct { // CLOUDNS_AUTH_ID and CLOUDNS_AUTH_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { var subAuthID string + authID := env.GetOrFile(EnvAuthID) if authID == "" { subAuthID = env.GetOrFile(EnvSubAuthID) @@ -96,7 +102,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, fmt.Errorf("ClouDNS: %w", err) } - client.HTTPClient = config.HTTPClient + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{client: client, config: config}, nil } @@ -159,14 +169,22 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { // waitNameservers At the time of writing 4 servers are found as authoritative, but 8 are reported during the sync. // If this is not done, the secondary verification done by Let's Encrypt server will fail quire a bit. func (d *DNSProvider) waitNameservers(ctx context.Context, domain string, zone *internal.Zone) error { - return wait.For("Nameserver sync on "+domain, d.config.PropagationTimeout, d.config.PollingInterval, func() (bool, error) { - syncProgress, err := d.client.GetUpdateStatus(ctx, zone.Name) - if err != nil { - return false, err - } + return wait.Retry(ctx, + func() error { + syncProgress, err := d.client.GetUpdateStatus(ctx, zone.Name) + if err != nil { + return fmt.Errorf("nameserver sync on %s: %w", domain, err) + } - log.Infof("[%s] Sync %d/%d complete", domain, syncProgress.Updated, syncProgress.Total) + log.Infof("[%s] Sync %d/%d complete", domain, syncProgress.Updated, syncProgress.Total) - return syncProgress.Complete, nil - }) + if !syncProgress.Complete { + return fmt.Errorf("nameserver sync on %s not complete", domain) + } + + return nil + }, + backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)), + backoff.WithMaxElapsedTime(d.config.PropagationTimeout), + ) } diff --git a/providers/dns/cloudns/cloudns.toml b/providers/dns/cloudns/cloudns.toml index dd81da462..ad52ef5b1 100644 --- a/providers/dns/cloudns/cloudns.toml +++ b/providers/dns/cloudns/cloudns.toml @@ -7,7 +7,7 @@ Since = "v2.3.0" Example = ''' CLOUDNS_AUTH_ID=xxxx \ CLOUDNS_AUTH_PASSWORD=yyyy \ -lego --email you@example.com --dns cloudns -d '*.example.com' -d example.com run +lego --dns cloudns -d '*.example.com' -d example.com run ''' [Configuration] @@ -16,10 +16,10 @@ lego --email you@example.com --dns cloudns -d '*.example.com' -d example.com run CLOUDNS_AUTH_PASSWORD = "The password for API user ID" [Configuration.Additional] CLOUDNS_SUB_AUTH_ID = "The API sub user ID" - CLOUDNS_POLLING_INTERVAL = "Time between DNS propagation check" - CLOUDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - CLOUDNS_TTL = "The TTL of the TXT record used for the DNS challenge" - CLOUDNS_HTTP_TIMEOUT = "API request timeout" + CLOUDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + CLOUDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 180)" + CLOUDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + CLOUDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.cloudns.net/wiki/article/42/" diff --git a/providers/dns/cloudns/cloudns_test.go b/providers/dns/cloudns/cloudns_test.go index ea4f25c95..024bd93d8 100644 --- a/providers/dns/cloudns/cloudns_test.go +++ b/providers/dns/cloudns/cloudns_test.go @@ -79,6 +79,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -169,6 +170,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -182,6 +184,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/cloudns/internal/client.go b/providers/dns/cloudns/internal/client.go index 60d7e6bbe..278b8de49 100644 --- a/providers/dns/cloudns/internal/client.go +++ b/providers/dns/cloudns/internal/client.go @@ -171,6 +171,7 @@ func (c *Client) ListTxtRecords(ctx context.Context, zoneName, fqdn string) ([]T } var records []TXTRecord + for _, record := range raw { if record.Host == subDomain && record.Type == "TXT" { records = append(records, record) @@ -279,6 +280,7 @@ func (c *Client) GetUpdateStatus(ctx context.Context, zoneName string) (*SyncPro } updatedCount := 0 + for _, record := range records { if record.Updated { updatedCount++ diff --git a/providers/dns/cloudns/internal/client_test.go b/providers/dns/cloudns/internal/client_test.go index 999bd1446..b9f6c5431 100644 --- a/providers/dns/cloudns/internal/client_test.go +++ b/providers/dns/cloudns/internal/client_test.go @@ -1,44 +1,26 @@ package internal import ( - "context" - "fmt" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, subAuthID string, handler http.HandlerFunc) *Client { - t.Helper() - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - client, err := NewClient("myAuthID", subAuthID, "myAuthPassword") - require.NoError(t, err) - - client.BaseURL, _ = url.Parse(server.URL) - client.HTTPClient = server.Client() - - return client -} - -func handlerMock(method string, jsonData []byte) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, "Incorrect method used", http.StatusBadRequest) - return - } - - _, err := rw.Write(jsonData) +func setupClient(subAuthID string) func(server *httptest.Server) (*Client, error) { + return func(server *httptest.Server) (*Client, error) { + client, err := NewClient("myAuthID", subAuthID, "myAuthPassword") if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return + return nil, err } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil } } @@ -97,7 +79,7 @@ func TestClient_GetZone(t *testing.T) { desc string authFQDN string apiResponse string - expected + expected expected }{ { desc: "zone found", @@ -132,9 +114,17 @@ func TestClient_GetZone(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse))) + client := servermock.NewBuilder[*Client](setupClient("")). + Route("GET /get-zone-info.json", + servermock.RawStringResponse(test.apiResponse), + servermock.CheckQueryParameter().Strict(). + With("auth-id", "myAuthID"). + With("auth-password", "myAuthPassword"). + With("domain-name", "foo.com"), + ). + Build(t) - zone, err := client.GetZone(context.Background(), test.authFQDN) + zone, err := client.GetZone(t.Context(), test.authFQDN) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) @@ -157,7 +147,7 @@ func TestClient_FindTxtRecord(t *testing.T) { authFQDN string zoneName string apiResponse string - expected + expected expected }{ { desc: "record found", @@ -239,9 +229,19 @@ func TestClient_FindTxtRecord(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse))) + client := servermock.NewBuilder[*Client](setupClient("")). + Route("GET /records.json", + servermock.RawStringResponse(test.apiResponse), + servermock.CheckQueryParameter().Strict(). + With("auth-id", "myAuthID"). + With("auth-password", "myAuthPassword"). + With("type", "TXT"). + With("host", "_acme-challenge"). + With("domain-name", test.zoneName), + ). + Build(t) - txtRecord, err := client.FindTxtRecord(context.Background(), test.zoneName, test.authFQDN) + txtRecord, err := client.FindTxtRecord(t.Context(), test.zoneName, test.authFQDN) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) @@ -264,7 +264,7 @@ func TestClient_ListTxtRecord(t *testing.T) { authFQDN string zoneName string apiResponse string - expected + expected expected }{ { desc: "record found", @@ -348,9 +348,19 @@ func TestClient_ListTxtRecord(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse))) + client := servermock.NewBuilder[*Client](setupClient("")). + Route("GET /records.json", + servermock.RawStringResponse(test.apiResponse), + servermock.CheckQueryParameter().Strict(). + With("auth-id", "myAuthID"). + With("auth-password", "myAuthPassword"). + With("type", "TXT"). + With("host", "_acme-challenge"). + With("domain-name", test.zoneName), + ). + Build(t) - txtRecords, err := client.ListTxtRecords(context.Background(), test.zoneName, test.authFQDN) + txtRecords, err := client.ListTxtRecords(t.Context(), test.zoneName, test.authFQDN) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) @@ -364,7 +374,7 @@ func TestClient_ListTxtRecord(t *testing.T) { func TestClient_AddTxtRecord(t *testing.T) { type expected struct { - query string + query url.Values errorMsg string } @@ -377,7 +387,7 @@ func TestClient_AddTxtRecord(t *testing.T) { value string ttl int apiResponse string - expected + expected expected }{ { desc: "sub-zone", @@ -388,7 +398,15 @@ func TestClient_AddTxtRecord(t *testing.T) { ttl: 60, apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge.foo&record=txtTXTtxtTXTtxtTXTtxtTXT&record-type=TXT&ttl=60`, + query: url.Values{ + "auth-id": {"myAuthID"}, + "auth-password": {"myAuthPassword"}, + "domain-name": {"example.com"}, + "host": {"_acme-challenge.foo"}, + "record": {"txtTXTtxtTXTtxtTXTtxtTXT"}, + "record-type": {"TXT"}, + "ttl": {"60"}, + }, }, }, { @@ -400,7 +418,15 @@ func TestClient_AddTxtRecord(t *testing.T) { ttl: 60, apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=60`, + query: url.Values{ + "auth-id": {"myAuthID"}, + "auth-password": {"myAuthPassword"}, + "domain-name": {"example.com"}, + "host": {"_acme-challenge"}, + "record": {"TXTtxtTXTtxtTXTtxtTXTtxt"}, + "record-type": {"TXT"}, + "ttl": {"60"}, + }, }, }, { @@ -412,7 +438,15 @@ func TestClient_AddTxtRecord(t *testing.T) { ttl: 60, apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ - query: `auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&sub-auth-id=mySubAuthID&ttl=60`, + query: url.Values{ + "auth-password": {"myAuthPassword"}, + "domain-name": {"example.com"}, + "host": {"_acme-challenge"}, + "record": {"TXTtxtTXTtxtTXTtxtTXTtxt"}, + "record-type": {"TXT"}, + "sub-auth-id": {"mySubAuthID"}, + "ttl": {"60"}, + }, }, }, { @@ -424,7 +458,15 @@ func TestClient_AddTxtRecord(t *testing.T) { ttl: 120, apiResponse: `{"status":"Failed","statusDescription":"Invalid TTL. Choose from the list of the values we support."}`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`, + query: url.Values{ + "auth-id": {"myAuthID"}, + "auth-password": {"myAuthPassword"}, + "domain-name": {"example.com"}, + "host": {"_acme-challenge"}, + "record": {"TXTtxtTXTtxtTXTtxtTXTtxt"}, + "record-type": {"TXT"}, + "ttl": {"300"}, + }, errorMsg: "failed to add TXT record: Failed Invalid TTL. Choose from the list of the values we support.", }, }, @@ -437,7 +479,15 @@ func TestClient_AddTxtRecord(t *testing.T) { ttl: 120, apiResponse: `[{}]`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`, + query: url.Values{ + "auth-id": {"myAuthID"}, + "auth-password": {"myAuthPassword"}, + "domain-name": {"example.com"}, + "host": {"_acme-challenge"}, + "record": {"TXTtxtTXTtxtTXTtxtTXTtxt"}, + "record-type": {"TXT"}, + "ttl": {"300"}, + }, errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.apiResponse", }, }, @@ -445,17 +495,15 @@ func TestClient_AddTxtRecord(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := setupTest(t, test.subAuthID, func(rw http.ResponseWriter, req *http.Request) { - if test.expected.query != req.URL.RawQuery { - msg := fmt.Sprintf("got: %s, want: %s", test.expected.query, req.URL.RawQuery) - http.Error(rw, msg, http.StatusBadRequest) - return - } + client := servermock.NewBuilder[*Client](setupClient(test.subAuthID)). + Route("POST /add-record.json", + servermock.RawStringResponse(test.apiResponse), + servermock.CheckQueryParameter().Strict(). + WithValues(test.expected.query), + ). + Build(t) - handlerMock(http.MethodPost, []byte(test.apiResponse))(rw, req) - }) - - err := client.AddTxtRecord(context.Background(), test.zoneName, test.authFQDN, test.value, test.ttl) + err := client.AddTxtRecord(t.Context(), test.zoneName, test.authFQDN, test.value, test.ttl) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) @@ -468,7 +516,7 @@ func TestClient_AddTxtRecord(t *testing.T) { func TestClient_RemoveTxtRecord(t *testing.T) { type expected struct { - query string + query url.Values errorMsg string } @@ -477,7 +525,7 @@ func TestClient_RemoveTxtRecord(t *testing.T) { id int zoneName string apiResponse string - expected + expected expected }{ { desc: "record found", @@ -485,7 +533,12 @@ func TestClient_RemoveTxtRecord(t *testing.T) { zoneName: "foo.com", apiResponse: `{ "status": "Success", "statusDescription": "The record was deleted successfully." }`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo.com&record-id=5769228`, + query: url.Values{ + "auth-id": {"myAuthID"}, + "auth-password": {"myAuthPassword"}, + "domain-name": {"foo.com"}, + "record-id": {"5769228"}, + }, }, }, { @@ -494,7 +547,12 @@ func TestClient_RemoveTxtRecord(t *testing.T) { zoneName: "foo.com", apiResponse: `{ "status": "Failed", "statusDescription": "Invalid record-id param." }`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo.com&record-id=5769000`, + query: url.Values{ + "auth-id": {"myAuthID"}, + "auth-password": {"myAuthPassword"}, + "domain-name": {"foo.com"}, + "record-id": {"5769000"}, + }, errorMsg: "failed to remove TXT record: Failed Invalid record-id param.", }, }, @@ -504,7 +562,12 @@ func TestClient_RemoveTxtRecord(t *testing.T) { zoneName: "foo-plus.com", apiResponse: `[{}]`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo-plus.com&record-id=44`, + query: url.Values{ + "auth-id": {"myAuthID"}, + "auth-password": {"myAuthPassword"}, + "domain-name": {"foo-plus.com"}, + "record-id": {"44"}, + }, errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.apiResponse", }, }, @@ -512,23 +575,15 @@ func TestClient_RemoveTxtRecord(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if test.expected.query != req.URL.RawQuery { - msg := fmt.Sprintf("got: %s, want: %s", test.expected.query, req.URL.RawQuery) - http.Error(rw, msg, http.StatusBadRequest) - return - } + client := servermock.NewBuilder[*Client](setupClient("")). + Route("POST /delete-record.json", + servermock.RawStringResponse(test.apiResponse), + servermock.CheckQueryParameter().Strict(). + WithValues(test.expected.query), + ). + Build(t) - handlerMock(http.MethodPost, []byte(test.apiResponse))(rw, req) - })) - t.Cleanup(server.Close) - - client, err := NewClient("myAuthID", "", "myAuthPassword") - require.NoError(t, err) - - client.BaseURL, _ = url.Parse(server.URL) - - err = client.RemoveTxtRecord(context.Background(), test.id, test.zoneName) + err := client.RemoveTxtRecord(t.Context(), test.id, test.zoneName) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) @@ -550,7 +605,7 @@ func TestClient_GetUpdateStatus(t *testing.T) { authFQDN string zoneName string apiResponse string - expected + expected expected }{ { desc: "50% sync", @@ -590,15 +645,17 @@ func TestClient_GetUpdateStatus(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - server := httptest.NewServer(handlerMock(http.MethodGet, []byte(test.apiResponse))) - t.Cleanup(server.Close) + client := servermock.NewBuilder[*Client](setupClient("")). + Route("GET /update-status.json", + servermock.RawStringResponse(test.apiResponse), + servermock.CheckQueryParameter().Strict(). + With("auth-id", "myAuthID"). + With("auth-password", "myAuthPassword"). + With("domain-name", test.zoneName), + ). + Build(t) - client, err := NewClient("myAuthID", "", "myAuthPassword") - require.NoError(t, err) - - client.BaseURL, _ = url.Parse(server.URL) - - syncProgress, err := client.GetUpdateStatus(context.Background(), test.zoneName) + syncProgress, err := client.GetUpdateStatus(t.Context(), test.zoneName) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) diff --git a/providers/dns/cloudru/cloudru.go b/providers/dns/cloudru/cloudru.go index 68ad21b26..dd597952a 100644 --- a/providers/dns/cloudru/cloudru.go +++ b/providers/dns/cloudru/cloudru.go @@ -10,9 +10,11 @@ import ( "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/cloudru/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -30,6 +32,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { ServiceInstanceID string @@ -57,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 } @@ -96,6 +101,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, diff --git a/providers/dns/cloudru/cloudru.toml b/providers/dns/cloudru/cloudru.toml index f795c7ac4..b74098a72 100644 --- a/providers/dns/cloudru/cloudru.toml +++ b/providers/dns/cloudru/cloudru.toml @@ -8,7 +8,7 @@ Example = ''' CLOUDRU_SERVICE_INSTANCE_ID=ppp \ CLOUDRU_KEY_ID=xxx \ CLOUDRU_SECRET=yyy \ -lego --email you@example.com --dns cloudru -d '*.example.com' -d example.com run +lego --dns cloudru -d '*.example.com' -d example.com run ''' [Configuration] @@ -17,11 +17,11 @@ lego --email you@example.com --dns cloudru -d '*.example.com' -d example.com run CLOUDRU_KEY_ID = "Key ID (login)" CLOUDRU_SECRET = "Key Secret" [Configuration.Additional] - CLOUDRU_POLLING_INTERVAL = "Time between DNS propagation check" - CLOUDRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - CLOUDRU_TTL = "The TTL of the TXT record used for the DNS challenge" - CLOUDRU_HTTP_TIMEOUT = "API request timeout" - CLOUDRU_SEQUENCE_INTERVAL = "Time between sequential requests" + CLOUDRU_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" + CLOUDRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" + CLOUDRU_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + CLOUDRU_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + CLOUDRU_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 120)" [Links] API = "https://cloud.ru/ru/docs/clouddns/ug/topics/api-ref.html" diff --git a/providers/dns/cloudru/cloudru_test.go b/providers/dns/cloudru/cloudru_test.go index 88addde93..3e506cb1c 100644 --- a/providers/dns/cloudru/cloudru_test.go +++ b/providers/dns/cloudru/cloudru_test.go @@ -67,6 +67,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -153,6 +154,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -166,6 +168,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/cloudru/internal/client.go b/providers/dns/cloudru/internal/client.go index cb62c5bca..a00ae6ea8 100644 --- a/providers/dns/cloudru/internal/client.go +++ b/providers/dns/cloudru/internal/client.go @@ -61,6 +61,7 @@ func (c *Client) GetZones(ctx context.Context, parentID string) ([]Zone, error) } var zones APIResponse[Zone] + err = c.do(req, &zones) if err != nil { return nil, err @@ -78,6 +79,7 @@ func (c *Client) GetRecords(ctx context.Context, zoneID string) ([]Record, error } var records APIResponse[Record] + err = c.do(req, &records) if err != nil { return nil, err @@ -95,6 +97,7 @@ func (c *Client) CreateRecord(ctx context.Context, zoneID string, record Record) } var result Record + err = c.do(req, &result) if err != nil { return nil, err diff --git a/providers/dns/cloudru/internal/client_test.go b/providers/dns/cloudru/internal/client_test.go index d96183d9f..3b087d617 100644 --- a/providers/dns/cloudru/internal/client_test.go +++ b/providers/dns/cloudru/internal/client_test.go @@ -1,64 +1,42 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" "time" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.APIEndpoint, _ = url.Parse(server.URL) + client.token = &Token{ + AccessToken: "secret", + ExpiresIn: 60, + TokenType: "Bearer", + Deadline: time.Now().Add(1 * time.Minute), + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, handler) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.APIEndpoint, _ = url.Parse(server.URL) - client.token = &Token{ - AccessToken: "secret", - ExpiresIn: 60, - TokenType: "Bearer", - Deadline: time.Now().Add(1 * time.Minute), - } - - return client -} - -func writeFixtureHandler(method, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) - } + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer xxx")) } func TestClient_GetZones(t *testing.T) { - client := setupTest(t, "/zones", writeFixtureHandler(http.MethodGet, "zones.json")) + client := mockBuilder(). + Route("GET /zones", + servermock.ResponseFromFixture("zones.json")). + Build(t) - ctx := mockContext() + ctx := mockContext(t) zones, err := client.GetZones(ctx, "xxx") require.NoError(t, err) @@ -78,9 +56,12 @@ func TestClient_GetZones(t *testing.T) { } func TestClient_GetRecords(t *testing.T) { - client := setupTest(t, "/zones/zzz/records", writeFixtureHandler(http.MethodGet, "records.json")) + client := mockBuilder(). + Route("GET /zones/zzz/records", + servermock.ResponseFromFixture("records.json")). + Build(t) - ctx := mockContext() + ctx := mockContext(t) records, err := client.GetRecords(ctx, "zzz") require.NoError(t, err) @@ -122,9 +103,13 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_CreateRecord(t *testing.T) { - client := setupTest(t, "/zones/zzz/records", writeFixtureHandler(http.MethodPost, "record.json")) + client := mockBuilder(). + Route("POST /zones/zzz/records", + servermock.ResponseFromFixture("record.json"), + servermock.CheckRequestJSONBody(`{"name":"www.example.com.","type":"TXT","values":["text"],"ttl":"3600"}`)). + Build(t) - ctx := mockContext() + ctx := mockContext(t) recordReq := Record{ Name: "www.example.com.", @@ -150,9 +135,12 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "/zones/zzz/records/example.com/TXT", writeFixtureHandler(http.MethodDelete, "record.json")) + client := mockBuilder(). + Route("DELETE /zones/zzz/records/example.com/TXT", + servermock.ResponseFromFixture("record.json")). + Build(t) - ctx := mockContext() + ctx := mockContext(t) err := client.DeleteRecord(ctx, "zzz", "example.com", "TXT") require.NoError(t, err) diff --git a/providers/dns/cloudru/internal/identity.go b/providers/dns/cloudru/internal/identity.go index 79df3c297..3bb09f3fa 100644 --- a/providers/dns/cloudru/internal/identity.go +++ b/providers/dns/cloudru/internal/identity.go @@ -49,6 +49,7 @@ func (c *Client) obtainToken(ctx context.Context) (*Token, error) { } tok := Token{} + err = json.Unmarshal(raw, &tok) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) @@ -88,6 +89,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errResp := &authResponseError{} + err := json.Unmarshal(raw, errResp) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/cloudru/internal/identity_test.go b/providers/dns/cloudru/internal/identity_test.go index 7329e7f55..c1097c015 100644 --- a/providers/dns/cloudru/internal/identity_test.go +++ b/providers/dns/cloudru/internal/identity_test.go @@ -2,65 +2,51 @@ package internal import ( "context" - "encoding/json" - "fmt" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func mockContext() context.Context { - return context.WithValue(context.Background(), tokenKey, &Token{AccessToken: "xxx"}) +func mockContext(t *testing.T) context.Context { + t.Helper() + + return context.WithValue(t.Context(), tokenKey, &Token{AccessToken: "xxx"}) } -func tokenHandler(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("invalid method, got %s want %s", req.Method, http.MethodPost), http.StatusMethodNotAllowed) - return - } - - err := req.ParseForm() - if err != nil { - http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - grantType := req.Form.Get("grant_type") - clientID := req.Form.Get("client_id") - clientSecret := req.Form.Get("client_secret") - - if clientID != "user" || clientSecret != "secret" || grantType != "access_key" { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - _ = json.NewEncoder(rw).Encode(Token{ - AccessToken: "xxx", - TokenID: "yyy", - ExpiresIn: 666, - TokenType: "Bearer", - Scope: "openid profile email roles", - }) -} - -func TestClient_obtainToken(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", tokenHandler) - +func setupIdentityClient(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.AuthEndpoint, _ = url.Parse(server.URL) + return client, nil +} + +func TestClient_obtainToken(t *testing.T) { + client := servermock.NewBuilder[*Client](setupIdentityClient, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + ). + Route("POST /", servermock.JSONEncode(Token{ + AccessToken: "xxx", + TokenID: "yyy", + ExpiresIn: 666, + TokenType: "Bearer", + Scope: "openid profile email roles", + }), + servermock.CheckForm().Strict(). + With("client_id", "user"). + With("client_secret", "secret"). + With("grant_type", "access_key"), + ). + Build(t) + assert.Nil(t, client.token) - tok, err := client.obtainToken(context.Background()) + tok, err := client.obtainToken(t.Context()) require.NoError(t, err) assert.NotNil(t, tok) @@ -69,19 +55,27 @@ func TestClient_obtainToken(t *testing.T) { } func TestClient_CreateAuthenticatedContext(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", tokenHandler) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.AuthEndpoint, _ = url.Parse(server.URL) + client := servermock.NewBuilder[*Client](setupIdentityClient, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + ). + Route("POST /", servermock.JSONEncode(Token{ + AccessToken: "xxx", + TokenID: "yyy", + ExpiresIn: 666, + TokenType: "Bearer", + Scope: "openid profile email roles", + }), + servermock.CheckForm().Strict(). + With("client_id", "user"). + With("client_secret", "secret"). + With("grant_type", "access_key"), + ). + Build(t) assert.Nil(t, client.token) - ctx, err := client.CreateAuthenticatedContext(context.Background()) + ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) tok := getToken(ctx) diff --git a/providers/dns/cloudru/internal/types.go b/providers/dns/cloudru/internal/types.go index d233c73bc..713fd459a 100644 --- a/providers/dns/cloudru/internal/types.go +++ b/providers/dns/cloudru/internal/types.go @@ -38,9 +38,9 @@ type Zone struct { Valid bool `json:"valid,omitempty"` ValidationText string `json:"validationText,omitempty"` Delegated bool `json:"delegated,omitempty"` - LastCheck time.Time `json:"lastCheck,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + LastCheck time.Time `json:"lastCheck,omitzero"` + CreatedAt time.Time `json:"created_at,omitzero"` + UpdatedAt time.Time `json:"updated_at,omitzero"` } type Record struct { diff --git a/providers/dns/cloudxns/cloudxns.toml b/providers/dns/cloudxns/cloudxns.toml index 1486cc4fa..32eae8beb 100644 --- a/providers/dns/cloudxns/cloudxns.toml +++ b/providers/dns/cloudxns/cloudxns.toml @@ -9,7 +9,7 @@ Since = "v0.5.0" Example = ''' CLOUDXNS_API_KEY=xxxx \ CLOUDXNS_SECRET_KEY=yyyy \ -lego --email you@example.com --dns cloudxns -d '*.example.com' -d example.com run +lego --dns cloudxns -d '*.example.com' -d example.com run ''' [Configuration] @@ -17,7 +17,7 @@ lego --email you@example.com --dns cloudxns -d '*.example.com' -d example.com ru CLOUDXNS_API_KEY = "The API key" CLOUDXNS_SECRET_KEY = "The API secret key" [Configuration.Additional] - CLOUDXNS_POLLING_INTERVAL = "Time between DNS propagation check" - CLOUDXNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - CLOUDXNS_TTL = "The TTL of the TXT record used for the DNS challenge" - CLOUDXNS_HTTP_TIMEOUT = "API request timeout" + CLOUDXNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: )" + CLOUDXNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: )" + CLOUDXNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: )" + CLOUDXNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: )" diff --git a/providers/dns/com35/com35.go b/providers/dns/com35/com35.go new file mode 100644 index 000000000..4a9de3a18 --- /dev/null +++ b/providers/dns/com35/com35.go @@ -0,0 +1,104 @@ +// Package com35 implements a DNS provider for solving the DNS-01 challenge using 35.com/三五互联. +package com35 + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/westcn" +) + +// Environment variables names. +const ( + envNamespace = "COM35_" + + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "PASSWORD" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +const defaultBaseURL = "https://api.35.cn/api/v2" + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config = westcn.Config + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, 60), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + prv challenge.ProviderTimeout +} + +// NewDNSProvider returns a DNSProvider instance configured for 35.com/三五互联. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvPassword) + if err != nil { + return nil, fmt.Errorf("35com: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for 35.com/三五互联. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("35com: the configuration of the DNS provider is nil") + } + + provider, err := westcn.NewDNSProviderConfig(config, defaultBaseURL) + if err != nil { + return nil, fmt.Errorf("35com: %w", err) + } + + return &DNSProvider{prv: provider}, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + err := d.prv.Present(domain, token, keyAuth) + if err != nil { + return fmt.Errorf("35com: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + err := d.prv.CleanUp(domain, token, keyAuth) + if err != nil { + return fmt.Errorf("35com: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.prv.Timeout() +} diff --git a/providers/dns/com35/com35.toml b/providers/dns/com35/com35.toml new file mode 100644 index 000000000..386ee0043 --- /dev/null +++ b/providers/dns/com35/com35.toml @@ -0,0 +1,24 @@ +Name = "35.com/三五互联" +Description = '''''' +URL = "https://www.35.cn/" +Code = "com35" +Since = "v4.31.0" + +Example = ''' +COM35_USERNAME="xxx" \ +COM35_PASSWORD="yyy" \ +lego --dns com35 -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + COM35_USERNAME = "Username" + COM35_PASSWORD = "API password" + [Configuration.Additional] + COM35_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + COM35_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + COM35_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + COM35_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://api.35.cn/CustomerCenter/doc/domain_v2.html" diff --git a/providers/dns/com35/com35_test.go b/providers/dns/com35/com35_test.go new file mode 100644 index 000000000..78fd8f829 --- /dev/null +++ b/providers/dns/com35/com35_test.go @@ -0,0 +1,144 @@ +package com35 + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "secret", + }, + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvUsername: "", + EnvPassword: "secret", + }, + expected: "35com: some credentials information are missing: COM35_USERNAME", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "", + }, + expected: "35com: some credentials information are missing: COM35_PASSWORD", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "35com: some credentials information are missing: COM35_USERNAME,COM35_PASSWORD", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.prv) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + username string + password string + expected string + }{ + { + desc: "success", + username: "user", + password: "secret", + }, + { + desc: "missing username", + password: "secret", + expected: "35com: credentials missing", + }, + { + desc: "missing password", + username: "user", + expected: "35com: credentials missing", + }, + { + desc: "missing credentials", + expected: "35com: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Username = test.username + config.Password = test.password + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.prv) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/conoha/conoha.go b/providers/dns/conoha/conoha.go index 32755b9f3..f7658647c 100644 --- a/providers/dns/conoha/conoha.go +++ b/providers/dns/conoha/conoha.go @@ -8,9 +8,11 @@ import ( "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/conoha/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -28,6 +30,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Region string @@ -95,6 +99,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { identifier.HTTPClient = config.HTTPClient } + identifier.HTTPClient = clientdebug.Wrap(identifier.HTTPClient) + auth := internal.Auth{ TenantID: config.TenantID, PasswordCredentials: internal.PasswordCredentials{ @@ -117,6 +123,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } diff --git a/providers/dns/conoha/conoha.toml b/providers/dns/conoha/conoha.toml index 87903365f..be90acb0d 100644 --- a/providers/dns/conoha/conoha.toml +++ b/providers/dns/conoha/conoha.toml @@ -1,4 +1,4 @@ -Name = "ConoHa" +Name = "ConoHa v2" Description = '''''' URL = "https://www.conoha.jp/" Code = "conoha" @@ -8,7 +8,7 @@ Example = ''' CONOHA_TENANT_ID=487727e3921d44e3bfe7ebb337bf085e \ CONOHA_API_USERNAME=xxxx \ CONOHA_API_PASSWORD=yyyy \ -lego --email you@example.com --dns conoha -d '*.example.com' -d example.com run +lego --dns conoha -d '*.example.com' -d example.com run ''' [Configuration] @@ -17,11 +17,11 @@ lego --email you@example.com --dns conoha -d '*.example.com' -d example.com run CONOHA_API_USERNAME = "The API username" CONOHA_API_PASSWORD = "The API password" [Configuration.Additional] - CONOHA_POLLING_INTERVAL = "Time between DNS propagation check" - CONOHA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - CONOHA_TTL = "The TTL of the TXT record used for the DNS challenge" - CONOHA_HTTP_TIMEOUT = "API request timeout" - CONOHA_REGION = "The region" + CONOHA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + CONOHA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + CONOHA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + CONOHA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + CONOHA_REGION = "The region (Default: tyo1)" [Links] - API = "https://www.conoha.jp/docs/" + API = "https://doc.conoha.jp/reference/api-vps2/api-dns-vps2" diff --git a/providers/dns/conoha/conoha_test.go b/providers/dns/conoha/conoha_test.go index 9db5ba79f..c1c445d48 100644 --- a/providers/dns/conoha/conoha_test.go +++ b/providers/dns/conoha/conoha_test.go @@ -72,6 +72,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -155,6 +156,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -168,6 +170,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/conoha/internal/client.go b/providers/dns/conoha/internal/client.go index 87fbe5a0b..2f039489b 100644 --- a/providers/dns/conoha/internal/client.go +++ b/providers/dns/conoha/internal/client.go @@ -25,7 +25,7 @@ type Client struct { } // NewClient returns a client instance logged into the ConoHa service. -func NewClient(region string, token string) (*Client, error) { +func NewClient(region, token string) (*Client, error) { baseURL, err := url.Parse(fmt.Sprintf(dnsServiceBaseURL, region)) if err != nil { return nil, err @@ -54,7 +54,7 @@ func (c *Client) GetDomainID(ctx context.Context, domainName string) (string, er return "", fmt.Errorf("no such domain: %s", domainName) } -// https://www.conoha.jp/docs/paas-dns-list-domains.php +// https://doc.conoha.jp/reference/api-vps2/api-dns-vps2/paas-dns-list-domains-v2/?btn_id=reference-api-vps2--sidebar_reference-paas-dns-list-domains-v2 func (c *Client) getDomains(ctx context.Context) (*DomainListResponse, error) { endpoint := c.baseURL.JoinPath("v1", "domains") @@ -89,7 +89,7 @@ func (c *Client) GetRecordID(ctx context.Context, domainID, recordName, recordTy return "", errors.New("no such record") } -// https://www.conoha.jp/docs/paas-dns-list-records-in-a-domain.php +// https://doc.conoha.jp/reference/api-vps2/api-dns-vps2/paas-dns-list-records-in-a-domain-v2/?btn_id=reference-paas-dns-list-domains-v2--sidebar_reference-paas-dns-list-records-in-a-domain-v2 func (c *Client) getRecords(ctx context.Context, domainID string) (*RecordListResponse, error) { endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records") @@ -114,7 +114,7 @@ func (c *Client) CreateRecord(ctx context.Context, domainID string, record Recor return err } -// https://www.conoha.jp/docs/paas-dns-create-record.php +// https://doc.conoha.jp/reference/api-vps2/api-dns-vps2/paas-dns-create-record-v2/?btn_id=reference-paas-dns-list-records-in-a-domain-v2--sidebar_reference-paas-dns-create-record-v2 func (c *Client) createRecord(ctx context.Context, domainID string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records") @@ -124,6 +124,7 @@ func (c *Client) createRecord(ctx context.Context, domainID string, record Recor } newRecord := &Record{} + err = c.do(req, newRecord) if err != nil { return nil, err @@ -133,7 +134,7 @@ func (c *Client) createRecord(ctx context.Context, domainID string, record Recor } // DeleteRecord removes specified record. -// https://www.conoha.jp/docs/paas-dns-delete-a-record.php +// https://doc.conoha.jp/reference/api-vps2/api-dns-vps2/paas-dns-delete-a-record-v2/?btn_id=reference-paas-dns-create-record-v2--sidebar_reference-paas-dns-delete-a-record-v2 func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID string) error { endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records", recordID) diff --git a/providers/dns/conoha/internal/client_test.go b/providers/dns/conoha/internal/client_test.go index bc27ec212..5e06ffc1d 100644 --- a/providers/dns/conoha/internal/client_test.go +++ b/providers/dns/conoha/internal/client_test.go @@ -2,7 +2,6 @@ package internal import ( "bytes" - "context" "fmt" "io" "net/http" @@ -12,60 +11,26 @@ import ( "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("tyo1", "secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - client, err := NewClient("tyo1", "secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func writeFixtureHandler(method, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - writeFixture(rw, filename) - } -} - -func writeBodyHandler(method, content string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - _, err := fmt.Fprint(rw, content) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} - -func writeFixture(rw http.ResponseWriter, filename string) { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With("X-Auth-Token", "secret")) } func TestClient_GetDomainID(t *testing.T) { @@ -77,36 +42,36 @@ func TestClient_GetDomainID(t *testing.T) { testCases := []struct { desc string domainName string - handler http.HandlerFunc + response string expected expected }{ { desc: "success", domainName: "domain1.com.", - handler: writeFixtureHandler(http.MethodGet, "domains_GET.json"), + response: "domains_GET.json", expected: expected{domainID: "09494b72-b65b-4297-9efb-187f65a0553e"}, }, { desc: "non existing domain", domainName: "domain1.com.", - handler: writeBodyHandler(http.MethodGet, "{}"), + response: "empty.json", expected: expected{error: true}, }, { desc: "marshaling error", domainName: "domain1.com.", - handler: writeBodyHandler(http.MethodGet, "[]"), + response: "empty.json", expected: expected{error: true}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /v1/domains", servermock.ResponseFromFixture(test.response)). + Build(t) - mux.Handle("/v1/domains", test.handler) - - domainID, err := client.GetDomainID(context.Background(), test.domainName) + domainID, err := client.GetDomainID(t.Context(), test.domainName) if test.expected.error { require.Error(t, err) @@ -127,16 +92,12 @@ func TestClient_CreateRecord(t *testing.T) { { desc: "success", handler: func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - raw, err := io.ReadAll(req.Body) if err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } + defer func() { _ = req.Body.Close() }() if string(bytes.TrimSpace(raw)) != `{"name":"lego.com.","type":"TXT","data":"txtTXTtxt","ttl":300}` { @@ -144,18 +105,21 @@ func TestClient_CreateRecord(t *testing.T) { return } - writeFixture(rw, "domains-records_POST.json") + file, err := os.Open(filepath.Join("fixtures", "domains-records_POST.json")) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + defer func() { _ = file.Close() }() + + _, _ = io.Copy(rw, file) }, assert: require.NoError, }, { desc: "bad request", handler: func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - http.Error(rw, "OOPS", http.StatusBadRequest) }, assert: require.Error, @@ -164,9 +128,9 @@ func TestClient_CreateRecord(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/v1/domains/lego/records", test.handler) + client := mockBuilder(). + Route("POST /v1/domains/lego/records", test.handler). + Build(t) domainID := "lego" @@ -177,36 +141,30 @@ func TestClient_CreateRecord(t *testing.T) { TTL: 300, } - err := client.CreateRecord(context.Background(), domainID, record) + err := client.CreateRecord(t.Context(), domainID, record) test.assert(t, err) }) } } func TestClient_GetRecordID(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records", + servermock.ResponseFromFixture("domains-records_GET.json")). + Build(t) - mux.HandleFunc("/v1/domains/89acac79-38e7-497d-807c-a011e1310438/records", - writeFixtureHandler(http.MethodGet, "domains-records_GET.json")) - - recordID, err := client.GetRecordID(context.Background(), "89acac79-38e7-497d-807c-a011e1310438", "www.example.com.", "A", "15.185.172.153") + recordID, err := client.GetRecordID(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "www.example.com.", "A", "15.185.172.153") require.NoError(t, err) assert.Equal(t, "2e32e609-3a4f-45ba-bdef-e50eacd345ad", recordID) } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records/2e32e609-3a4f-45ba-bdef-e50eacd345ad", + servermock.ResponseFromFixture("domains-records_GET.json")). + Build(t) - mux.HandleFunc("/v1/domains/89acac79-38e7-497d-807c-a011e1310438/records/2e32e609-3a4f-45ba-bdef-e50eacd345ad", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - rw.WriteHeader(http.StatusOK) - }) - - err := client.DeleteRecord(context.Background(), "89acac79-38e7-497d-807c-a011e1310438", "2e32e609-3a4f-45ba-bdef-e50eacd345ad") + err := client.DeleteRecord(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "2e32e609-3a4f-45ba-bdef-e50eacd345ad") require.NoError(t, err) } diff --git a/providers/dns/conoha/internal/fixtures/empty.json b/providers/dns/conoha/internal/fixtures/empty.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/providers/dns/conoha/internal/fixtures/empty.json @@ -0,0 +1 @@ +{} diff --git a/providers/dns/conoha/internal/identity.go b/providers/dns/conoha/internal/identity.go index 995d55bb6..54fc46bc5 100644 --- a/providers/dns/conoha/internal/identity.go +++ b/providers/dns/conoha/internal/identity.go @@ -33,7 +33,7 @@ func NewIdentifier(region string) (*Identifier, error) { } // GetToken gets valid token information. -// https://www.conoha.jp/docs/identity-post_tokens.php +// https://doc.conoha.jp/reference/api-vps2/api-identity-vps2/identity-post_tokens-v2/?btn_id=reference-paas-dns-delete-a-record-v2--sidebar_reference-identity-post_tokens-v2 func (c *Identifier) GetToken(ctx context.Context, auth Auth) (*IdentityResponse, error) { endpoint := c.baseURL.JoinPath("v2.0", "tokens") diff --git a/providers/dns/conoha/internal/identity_test.go b/providers/dns/conoha/internal/identity_test.go index 027c7f2c7..0bd4c936a 100644 --- a/providers/dns/conoha/internal/identity_test.go +++ b/providers/dns/conoha/internal/identity_test.go @@ -1,28 +1,33 @@ package internal import ( - "context" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestNewClient(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupIdentifier(server *httptest.Server) (*Identifier, error) { identifier, err := NewIdentifier("tyo1") - require.NoError(t, err) + if err != nil { + return nil, err + } identifier.HTTPClient = server.Client() identifier.baseURL, _ = url.Parse(server.URL) - mux.HandleFunc("/v2.0/tokens", writeFixtureHandler(http.MethodPost, "tokens_POST.json")) + return identifier, nil +} + +func TestNewClient(t *testing.T) { + identifier := servermock.NewBuilder[*Identifier](setupIdentifier, + servermock.CheckHeader().WithJSONHeaders(), + ). + Route("POST /v2.0/tokens", servermock.ResponseFromFixture("tokens_POST.json")). + Build(t) auth := Auth{ TenantID: "487727e3921d44e3bfe7ebb337bf085e", @@ -32,7 +37,7 @@ func TestNewClient(t *testing.T) { }, } - token, err := identifier.GetToken(context.Background(), auth) + token, err := identifier.GetToken(t.Context(), auth) require.NoError(t, err) expected := &IdentityResponse{Access: Access{Token: Token{ID: "sample00d88246078f2bexample788f7"}}} diff --git a/providers/dns/conohav3/conohav3.go b/providers/dns/conohav3/conohav3.go new file mode 100644 index 000000000..c1eace827 --- /dev/null +++ b/providers/dns/conohav3/conohav3.go @@ -0,0 +1,203 @@ +// Package conohav3 implements a DNS provider for solving the DNS-01 challenge using ConoHa VPS Ver 3.0 DNS. +package conohav3 + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/conohav3/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "CONOHAV3_" + + EnvRegion = envNamespace + "REGION" + EnvTenantID = envNamespace + "TENANT_ID" + EnvAPIUserID = envNamespace + "API_USER_ID" + EnvAPIPassword = envNamespace + "API_PASSWORD" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Region string + TenantID string + UserID string + Password string + TTL int + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + Region: env.GetOrDefaultString(EnvRegion, "c3j1"), + TTL: env.GetOrDefaultInt(EnvTTL, 60), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for ConoHa DNS. +// Credentials must be passed in the environment variables: +// CONOHAV3_TENANT_ID, CONOHAV3_API_USER_ID, CONOHAV3_API_PASSWORD. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvTenantID, EnvAPIUserID, EnvAPIPassword) + if err != nil { + return nil, fmt.Errorf("conohav3: %w", err) + } + + config := NewDefaultConfig() + config.TenantID = values[EnvTenantID] + config.UserID = values[EnvAPIUserID] + config.Password = values[EnvAPIPassword] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for ConoHa DNS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("conohav3: the configuration of the DNS provider is nil") + } + + if config.TenantID == "" || config.UserID == "" || config.Password == "" { + return nil, errors.New("conohav3: some credentials information are missing") + } + + identifier, err := internal.NewIdentifier(config.Region) + if err != nil { + return nil, fmt.Errorf("conohav3: failed to create identity client: %w", err) + } + + if config.HTTPClient != nil { + identifier.HTTPClient = config.HTTPClient + } + + identifier.HTTPClient = clientdebug.Wrap(identifier.HTTPClient) + + auth := internal.Auth{ + Identity: internal.Identity{ + Methods: []string{"password"}, + Password: internal.Password{ + User: internal.User{ + ID: config.UserID, + Password: config.Password, + }, + }, + }, + Scope: internal.Scope{ + Project: internal.Project{ + ID: config.TenantID, + }, + }, + } + + token, err := identifier.GetToken(context.Background(), auth) + if err != nil { + return nil, fmt.Errorf("conohav3: failed to log in: %w", err) + } + + client, err := internal.NewClient(config.Region, token) + if err != nil { + return nil, fmt.Errorf("conohav3: failed to create client: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{config: config, client: client}, nil +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("conohav3: could not find zone for domain %q: %w", domain, err) + } + + ctx := context.Background() + + id, err := d.client.GetDomainID(ctx, authZone) + if err != nil { + return fmt.Errorf("conohav3: failed to get domain ID: %w", err) + } + + record := internal.Record{ + Name: info.EffectiveFQDN, + Type: "TXT", + Data: info.Value, + TTL: d.config.TTL, + } + + err = d.client.CreateRecord(ctx, id, record) + if err != nil { + return fmt.Errorf("conohav3: failed to create record: %w", err) + } + + return nil +} + +// CleanUp clears ConoHa DNS TXT record. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("conohav3: could not find zone for domain %q: %w", domain, err) + } + + ctx := context.Background() + + domID, err := d.client.GetDomainID(ctx, authZone) + if err != nil { + return fmt.Errorf("conohav3: failed to get domain ID: %w", err) + } + + recID, err := d.client.GetRecordID(ctx, domID, info.EffectiveFQDN, "TXT", info.Value) + if err != nil { + return fmt.Errorf("conohav3: failed to get record ID: %w", err) + } + + err = d.client.DeleteRecord(ctx, domID, recID) + if err != nil { + return fmt.Errorf("conohav3: failed to delete record: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/conohav3/conohav3.toml b/providers/dns/conohav3/conohav3.toml new file mode 100644 index 000000000..e2c80259d --- /dev/null +++ b/providers/dns/conohav3/conohav3.toml @@ -0,0 +1,27 @@ +Name = "ConoHa v3" +Description = '''''' +URL = "https://www.conoha.jp/" +Code = "conohav3" +Since = "v4.24.0" + +Example = ''' +CONOHAV3_TENANT_ID=487727e3921d44e3bfe7ebb337bf085e \ +CONOHAV3_API_USER_ID=xxxx \ +CONOHAV3_API_PASSWORD=yyyy \ +lego --dns conohav3 -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + CONOHAV3_TENANT_ID = "Tenant ID" + CONOHAV3_API_USER_ID = "The API user ID" + CONOHAV3_API_PASSWORD = "The API password" + [Configuration.Additional] + CONOHAV3_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + CONOHAV3_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + CONOHAV3_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + CONOHAV3_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + CONOHAV3_REGION = "The region (Default: c3j1)" + +[Links] + API = "https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/" diff --git a/providers/dns/conohav3/conohav3_test.go b/providers/dns/conohav3/conohav3_test.go new file mode 100644 index 000000000..d68ea3ebb --- /dev/null +++ b/providers/dns/conohav3/conohav3_test.go @@ -0,0 +1,181 @@ +package conohav3 + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvTenantID, + EnvAPIUserID, + EnvAPIPassword). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "complete credentials, but login failed", + envVars: map[string]string{ + EnvTenantID: "tenant_id", + EnvAPIUserID: "api_user_id", + EnvAPIPassword: "api_password", + }, + expected: `conohav3: failed to log in: unexpected status code: [status code: 400] body: {"code": 400, "error": "user does not exist"}`, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvTenantID: "", + EnvAPIUserID: "", + EnvAPIPassword: "", + }, + expected: "conohav3: some credentials information are missing: CONOHAV3_TENANT_ID,CONOHAV3_API_USER_ID,CONOHAV3_API_PASSWORD", + }, + { + desc: "missing tenant id", + envVars: map[string]string{ + EnvTenantID: "", + EnvAPIUserID: "api_user_id", + EnvAPIPassword: "api_password", + }, + expected: "conohav3: some credentials information are missing: CONOHAV3_TENANT_ID", + }, + { + desc: "missing api user id", + envVars: map[string]string{ + EnvTenantID: "tenant_id", + EnvAPIUserID: "", + EnvAPIPassword: "api_password", + }, + expected: "conohav3: some credentials information are missing: CONOHAV3_API_USER_ID", + }, + { + desc: "missing api password", + envVars: map[string]string{ + EnvTenantID: "tenant_id", + EnvAPIUserID: "api_user_id", + EnvAPIPassword: "", + }, + expected: "conohav3: some credentials information are missing: CONOHAV3_API_PASSWORD", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + expected string + tenant string + userid string + password string + }{ + { + desc: "complete credentials, but login failed", + expected: `conohav3: failed to log in: unexpected status code: [status code: 400] body: {"code": 400, "error": "user does not exist"}`, + tenant: "tenant_id", + userid: "api_user_id", + password: "api_password", + }, + { + desc: "missing credentials", + expected: "conohav3: some credentials information are missing", + }, + { + desc: "missing tenant id", + expected: "conohav3: some credentials information are missing", + userid: "api_user_id", + password: "api_password", + }, + { + desc: "missing api user id", + expected: "conohav3: some credentials information are missing", + tenant: "tenant_id", + password: "api_password", + }, + { + desc: "missing api password", + expected: "conohav3: some credentials information are missing", + tenant: "tenant_id", + userid: "api_user_id", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.TenantID = test.tenant + config.UserID = test.userid + config.Password = test.password + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + time.Sleep(1 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/conohav3/internal/client.go b/providers/dns/conohav3/internal/client.go new file mode 100644 index 000000000..2a9e7c2bc --- /dev/null +++ b/providers/dns/conohav3/internal/client.go @@ -0,0 +1,204 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const dnsServiceBaseURL = "https://dns-service.%s.conoha.io" + +// Client is a ConoHa API client. +type Client struct { + token string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient returns a client instance logged into the ConoHa service. +func NewClient(region, token string) (*Client, error) { + baseURL, err := url.Parse(fmt.Sprintf(dnsServiceBaseURL, region)) + if err != nil { + return nil, err + } + + return &Client{ + token: token, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + }, nil +} + +// GetDomainID returns an ID of specified domain. +func (c *Client) GetDomainID(ctx context.Context, domainName string) (string, error) { + domainList, err := c.getDomains(ctx) + if err != nil { + return "", err + } + + for _, domain := range domainList.Domains { + if domain.Name == domainName { + return domain.UUID, nil + } + } + + return "", fmt.Errorf("no such domain: %s", domainName) +} + +// https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/dnsaas-get_domains_list-v3/?btn_id=reference-api-vps3--sidebar_reference-dnsaas-get_domains_list-v3 +func (c *Client) getDomains(ctx context.Context) (*DomainListResponse, error) { + endpoint := c.baseURL.JoinPath("v1", "domains") + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + domainList := &DomainListResponse{} + + err = c.do(req, domainList) + if err != nil { + return nil, err + } + + return domainList, nil +} + +// GetRecordID returns an ID of specified record. +func (c *Client) GetRecordID(ctx context.Context, domainID, recordName, recordType, data string) (string, error) { + recordList, err := c.getRecords(ctx, domainID) + if err != nil { + return "", err + } + + for _, record := range recordList.Records { + if record.Name == recordName && record.Type == recordType && record.Data == data { + return record.UUID, nil + } + } + + return "", errors.New("no such record") +} + +// https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/dnsaas-get_records_list-v3/?btn_id=reference-dnsaas-get_domains_list-v3--sidebar_reference-dnsaas-get_records_list-v3 +func (c *Client) getRecords(ctx context.Context, domainID string) (*RecordListResponse, error) { + endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records") + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + recordList := &RecordListResponse{} + + err = c.do(req, recordList) + if err != nil { + return nil, err + } + + return recordList, nil +} + +// CreateRecord adds new record. +func (c *Client) CreateRecord(ctx context.Context, domainID string, record Record) error { + _, err := c.createRecord(ctx, domainID, record) + return err +} + +// https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/dnsaas-create_record-v3/?btn_id=reference-dnsaas-get_records_list-v3--sidebar_reference-dnsaas-create_record-v3 +func (c *Client) createRecord(ctx context.Context, domainID string, record Record) (*Record, error) { + endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return nil, err + } + + newRecord := &Record{} + + err = c.do(req, newRecord) + if err != nil { + return nil, err + } + + return newRecord, nil +} + +// DeleteRecord removes specified record. +// https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/dnsaas-delete_record-v3/?btn_id=reference-dnsaas-create_record-v3--sidebar_reference-dnsaas-delete_record-v3 +func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID string) error { + endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records", recordID) + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) do(req *http.Request, result any) error { + if c.token != "" { + req.Header.Set("X-Auth-Token", c.token) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return errutils.NewUnexpectedResponseStatusCodeError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} diff --git a/providers/dns/conohav3/internal/client_test.go b/providers/dns/conohav3/internal/client_test.go new file mode 100644 index 000000000..66babae49 --- /dev/null +++ b/providers/dns/conohav3/internal/client_test.go @@ -0,0 +1,171 @@ +package internal + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("c3j1", "secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With("X-Auth-Token", "secret")) +} + +func TestClient_GetDomainID(t *testing.T) { + type expected struct { + domainID string + error bool + } + + testCases := []struct { + desc string + domainName string + response string + expected expected + }{ + { + desc: "success", + domainName: "domain1.com.", + response: "domains_GET.json", + expected: expected{domainID: "09494b72-b65b-4297-9efb-187f65a0553e"}, + }, + { + desc: "non existing domain", + domainName: "domain1.com.", + response: "empty.json", + expected: expected{error: true}, + }, + { + desc: "marshaling error", + domainName: "domain1.com.", + response: "empty.json", + expected: expected{error: true}, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + client := mockBuilder(). + Route("GET /v1/domains", servermock.ResponseFromFixture(test.response)). + Build(t) + + domainID, err := client.GetDomainID(t.Context(), test.domainName) + + if test.expected.error { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected.domainID, domainID) + } + }) + } +} + +func TestClient_CreateRecord(t *testing.T) { + testCases := []struct { + desc string + handler http.HandlerFunc + assert require.ErrorAssertionFunc + }{ + { + desc: "success", + handler: func(rw http.ResponseWriter, req *http.Request) { + raw, err := io.ReadAll(req.Body) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + defer func() { _ = req.Body.Close() }() + + if string(bytes.TrimSpace(raw)) != `{"name":"lego.com.","type":"TXT","data":"txtTXTtxt","ttl":300}` { + http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest) + return + } + + file, err := os.Open(filepath.Join("fixtures", "domains-records_POST.json")) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + defer func() { _ = file.Close() }() + + _, _ = io.Copy(rw, file) + }, + assert: require.NoError, + }, + { + desc: "bad request", + handler: func(rw http.ResponseWriter, req *http.Request) { + http.Error(rw, "OOPS", http.StatusBadRequest) + }, + assert: require.Error, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + client := mockBuilder(). + Route("POST /v1/domains/lego/records", test.handler). + Build(t) + + domainID := "lego" + + record := Record{ + Name: "lego.com.", + Type: "TXT", + Data: "txtTXTtxt", + TTL: 300, + } + + err := client.CreateRecord(t.Context(), domainID, record) + test.assert(t, err) + }) + } +} + +func TestClient_GetRecordID(t *testing.T) { + client := mockBuilder(). + Route("GET /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records", + servermock.ResponseFromFixture("domains-records_GET.json")). + Build(t) + + recordID, err := client.GetRecordID(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "www.example.com.", "A", "15.185.172.153") + require.NoError(t, err) + + assert.Equal(t, "2e32e609-3a4f-45ba-bdef-e50eacd345ad", recordID) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records/2e32e609-3a4f-45ba-bdef-e50eacd345ad", + servermock.ResponseFromFixture("domains-records_GET.json")). + Build(t) + + err := client.DeleteRecord(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "2e32e609-3a4f-45ba-bdef-e50eacd345ad") + require.NoError(t, err) +} diff --git a/providers/dns/conohav3/internal/fixtures/domains-records_GET.json b/providers/dns/conohav3/internal/fixtures/domains-records_GET.json new file mode 100644 index 000000000..f982c1911 --- /dev/null +++ b/providers/dns/conohav3/internal/fixtures/domains-records_GET.json @@ -0,0 +1,43 @@ +{ + "records": [ + { + "uuid": "2e32e609-3a4f-45ba-bdef-e50eacd345ad", + "name": "www.example.com.", + "type": "A", + "ttl": 3600, + "created_at": "2012-11-02T19:56:26.000000", + "updated_at": "2012-11-04T13:22:36.000000", + "data": "15.185.172.153", + "domain_id": "89acac79-38e7-497d-807c-a011e1310438", + "version": 1, + "gslb_region": "JP", + "gslb_weight": 250, + "gslb_check": 12300 + }, + { + "uuid": "8e9ecf3e-fb92-4a3a-a8ae-7596f167bea3", + "name": "host1.example.com.", + "type": "A", + "ttl": 3600, + "created_at": "2012-11-04T13:57:50.000000", + "updated_at": null, + "data": "15.185.172.154", + "domain_id": "89acac79-38e7-497d-807c-a011e1310438", + "version": 1, + "gslb_region": "US", + "gslb_weight": 220, + "gslb_check": 12200 + }, + { + "uuid": "4ad19089-3e62-40f8-9482-17cc8ccb92cb", + "name": "web.example.com.", + "type": "CNAME", + "ttl": 3600, + "created_at": "2012-11-04T13:58:16.393735", + "updated_at": null, + "data": "www.example.com.", + "domain_id": "89acac79-38e7-497d-807c-a011e1310438", + "version": 1 + } + ] +} diff --git a/providers/dns/conohav3/internal/fixtures/domains-records_POST.json b/providers/dns/conohav3/internal/fixtures/domains-records_POST.json new file mode 100644 index 000000000..d0f71c03e --- /dev/null +++ b/providers/dns/conohav3/internal/fixtures/domains-records_POST.json @@ -0,0 +1,13 @@ +{ + "uuid": "2e32e609-3a4f-45ba-bdef-e50eacd345ad", + "name": "www.example.com.", + "type": "A", + "created_at": "2012-11-02T19:56:26.366792", + "updated_at": null, + "domain_id": "89acac79-38e7-497d-807c-a011e1310438", + "ttl": null, + "data": "192.0.2.3", + "gslb_check": 1, + "gslb_region": "JP", + "gslb_weight": 250 +} diff --git a/providers/dns/conohav3/internal/fixtures/domains_GET.json b/providers/dns/conohav3/internal/fixtures/domains_GET.json new file mode 100644 index 000000000..6f8603a57 --- /dev/null +++ b/providers/dns/conohav3/internal/fixtures/domains_GET.json @@ -0,0 +1,25 @@ +{ + "domains": [ + { + "uuid": "09494b72-b65b-4297-9efb-187f65a0553e", + "name": "domain1.com.", + "project_id": "cf661142-e577-40b5-b3eb-75795cdc0cd7", + "serial": 1701909248, + "ttl": 3600, + "email": "nsadmin1@example.org", + "created_at": "2023-12-07T00:34:08Z", + "updated_at": "2023-12-07T00:34:08Z" + }, + { + "uuid": "cf661142-e577-40b5-b3eb-75795cdc0cd7", + "name": "domain2.com.", + "project_id": "cf661144-e578-39b6-b4eb-75794cdc1cd8", + "serial": 1351800670, + "ttl": 7200, + "email": "nsadmin2@example.org", + "created_at": "2012-11-01T20:11:08Z", + "updated_at": "2012-12-01T20:11:08Z" + } + ], + "total_count": 1 +} diff --git a/providers/dns/conohav3/internal/fixtures/empty.json b/providers/dns/conohav3/internal/fixtures/empty.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/providers/dns/conohav3/internal/fixtures/empty.json @@ -0,0 +1 @@ +{} diff --git a/providers/dns/conohav3/internal/identity.go b/providers/dns/conohav3/internal/identity.go new file mode 100644 index 000000000..6a9ad7f1e --- /dev/null +++ b/providers/dns/conohav3/internal/identity.go @@ -0,0 +1,71 @@ +// internal/identity.go + +package internal + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const identityBaseURL = "https://identity.%s.conoha.io" + +type Identifier struct { + baseURL *url.URL + HTTPClient *http.Client +} + +// NewIdentifier creates a new Identifier. +func NewIdentifier(region string) (*Identifier, error) { + baseURL, err := url.Parse(fmt.Sprintf(identityBaseURL, region)) + if err != nil { + return nil, err + } + + return &Identifier{ + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + }, nil +} + +// GetToken returns the x-subject-token from Identity API. +// https://doc.conoha.jp/reference/api-vps3/api-identity-vps3/identity-post_tokens-v3/?btn_id=reference-api-guideline-v3--sidebar_reference-identity-post_tokens-v3 +func (c *Identifier) GetToken(ctx context.Context, auth Auth) (string, error) { + endpoint := c.baseURL.JoinPath("v3", "auth", "tokens") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, &IdentityRequest{Auth: auth}) + if err != nil { + return "", err + } + + return c.do(req) +} + +// do sends the request and returns the token from x-subject-token header. +func (c *Identifier) do(req *http.Request) (string, error) { + resp, err := c.HTTPClient.Do(req) + if err != nil { + return "", errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + return "", errutils.NewUnexpectedResponseStatusCodeError(req, resp) + } + + token := resp.Header.Get("x-subject-token") + if token == "" { + return "", errors.New("x-subject-token header is missing in response") + } + + _, _ = io.Copy(io.Discard, resp.Body) + + return token, nil +} diff --git a/providers/dns/conohav3/internal/identity_test.go b/providers/dns/conohav3/internal/identity_test.go new file mode 100644 index 000000000..d479a18d9 --- /dev/null +++ b/providers/dns/conohav3/internal/identity_test.go @@ -0,0 +1,57 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupIdentifier(server *httptest.Server) (*Identifier, error) { + identifier, err := NewIdentifier("c3j1") + if err != nil { + return nil, err + } + + identifier.HTTPClient = server.Client() + identifier.baseURL, _ = url.Parse(server.URL) + + return identifier, nil +} + +func TestGetToken_HeaderToken(t *testing.T) { + identifier := servermock.NewBuilder[*Identifier](setupIdentifier, + servermock.CheckHeader().WithJSONHeaders(), + ). + Route("POST /v3/auth/tokens", + servermock.ResponseFromFixture("empty.json"). + WithStatusCode(http.StatusCreated). + WithHeader("x-subject-token", "sample-header-token-123")). + Build(t) + + auth := Auth{ + Identity: Identity{ + Methods: []string{"password"}, + Password: Password{ + User: User{ + ID: "dummy-id", + Password: "dummy-password", + }, + }, + }, + Scope: Scope{ + Project: Project{ + ID: "dummy-project-id", + }, + }, + } + + token, err := identifier.GetToken(t.Context(), auth) + require.NoError(t, err) + + assert.Equal(t, "sample-header-token-123", token) +} diff --git a/providers/dns/conohav3/internal/types.go b/providers/dns/conohav3/internal/types.go new file mode 100644 index 000000000..99a162dd0 --- /dev/null +++ b/providers/dns/conohav3/internal/types.go @@ -0,0 +1,65 @@ +package internal + +// IdentityRequest is the top-level payload sent to the Identity v3. +type IdentityRequest struct { + Auth Auth `json:"auth"` +} + +// Auth authentication credentials (Identity) and scope (Scope). +type Auth struct { + Identity Identity `json:"identity"` + Scope Scope `json:"scope"` +} + +// Identity describes how the client will authenticate. +// In ConoHa v3.0, only support the "password" method. +type Identity struct { + Methods []string `json:"methods"` + Password Password `json:"password"` +} + +// Password nests the concrete user credentials used by the password auth method. +type Password struct { + User User `json:"user"` +} + +// User holds the API User ID and password that will be verified by the Identity service. +type User struct { + ID string `json:"id"` + Password string `json:"password"` +} + +// Scope specifies which tenant the issued token should be scoped to. +type Scope struct { + Project Project `json:"project"` +} + +// Project identifies the target tenant by UUID. +type Project struct { + ID string `json:"id"` +} + +// DomainListResponse is returned by `GET /v1/domains` and contains all DNS zones (domains) owned by the project. +type DomainListResponse struct { + Domains []Domain `json:"domains"` +} + +// Domain represents a single hosted DNS zone. +type Domain struct { + UUID string `json:"uuid"` + Name string `json:"name"` +} + +// RecordListResponse is returned by `GET /v1/domains/{domain_uuid}/records` and lists every record in the zone. +type RecordListResponse struct { + Records []Record `json:"records"` +} + +// Record represents a DNS record inside a zone. +type Record struct { + UUID string `json:"uuid,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Data string `json:"data"` + TTL int `json:"ttl"` +} diff --git a/providers/dns/constellix/constellix.go b/providers/dns/constellix/constellix.go index a7e81371d..777e93308 100644 --- a/providers/dns/constellix/constellix.go +++ b/providers/dns/constellix/constellix.go @@ -10,9 +10,11 @@ import ( "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/constellix/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/hashicorp/go-retryablehttp" ) @@ -29,6 +31,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -93,7 +97,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { retryClient.HTTPClient = tr.Wrap(config.HTTPClient) retryClient.Backoff = backoff - client := internal.NewClient(retryClient.StandardClient()) + client := internal.NewClient(clientdebug.Wrap(retryClient.StandardClient())) return &DNSProvider{config: config, client: client}, nil } @@ -196,6 +200,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("constellix: failed to delete TXT records: %w", err) } + return nil } diff --git a/providers/dns/constellix/constellix.toml b/providers/dns/constellix/constellix.toml index 02442d31d..171a0de99 100644 --- a/providers/dns/constellix/constellix.toml +++ b/providers/dns/constellix/constellix.toml @@ -7,7 +7,7 @@ Since = "v3.4.0" Example = ''' CONSTELLIX_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ CONSTELLIX_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ -lego --email you@example.com --dns constellix -d '*.example.com' -d example.com run +lego --dns constellix -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,10 +15,10 @@ lego --email you@example.com --dns constellix -d '*.example.com' -d example.com CONSTELLIX_API_KEY = "User API key" CONSTELLIX_SECRET_KEY = "User secret key" [Configuration.Additional] - CONSTELLIX_POLLING_INTERVAL = "Time between DNS propagation check" - CONSTELLIX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - CONSTELLIX_TTL = "The TTL of the TXT record used for the DNS challenge" - CONSTELLIX_HTTP_TIMEOUT = "API request timeout" + CONSTELLIX_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + CONSTELLIX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + CONSTELLIX_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + CONSTELLIX_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api-docs.constellix.com" diff --git a/providers/dns/constellix/constellix_test.go b/providers/dns/constellix/constellix_test.go index e3a30caca..e38258292 100644 --- a/providers/dns/constellix/constellix_test.go +++ b/providers/dns/constellix/constellix_test.go @@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -129,6 +130,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -142,6 +144,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/constellix/internal/auth.go b/providers/dns/constellix/internal/auth.go index 1a136012d..9193572eb 100644 --- a/providers/dns/constellix/internal/auth.go +++ b/providers/dns/constellix/internal/auth.go @@ -28,6 +28,7 @@ func NewTokenTransport(apiKey, secretKey string) (*TokenTransport, error) { if apiKey == "" { return nil, errors.New("credentials missing: API key") } + if secretKey == "" { return nil, errors.New("credentials missing: secret key") } @@ -57,6 +58,7 @@ func (t *TokenTransport) transport() http.RoundTripper { if t.Transport != nil { return t.Transport } + return http.DefaultTransport } diff --git a/providers/dns/constellix/internal/domains.go b/providers/dns/constellix/internal/domains.go index 485f0d537..fa7027f55 100644 --- a/providers/dns/constellix/internal/domains.go +++ b/providers/dns/constellix/internal/domains.go @@ -30,10 +30,12 @@ func (s *DomainService) GetAll(ctx context.Context, params *PaginationParameters if errQ != nil { return nil, errQ } + req.URL.RawQuery = v.Encode() } var domains []Domain + err = s.client.do(req, &domains) if err != nil { return nil, err @@ -78,6 +80,7 @@ func (s *DomainService) Search(ctx context.Context, filter searchFilter, value s req.URL.RawQuery = query.Encode() var domains []Domain + err = s.client.do(req, &domains) if err != nil { var nf *NotFound diff --git a/providers/dns/constellix/internal/domains_test.go b/providers/dns/constellix/internal/domains_test.go index 1b0779b3d..468db4613 100644 --- a/providers/dns/constellix/internal/domains_test.go +++ b/providers/dns/constellix/internal/domains_test.go @@ -1,94 +1,57 @@ package internal import ( - "context" - "io" - "net/http" "net/http/httptest" - "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(server.Client()) + client.BaseURL = server.URL - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(server.Client()) - client.BaseURL = server.URL - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestDomainService_GetAll(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /v1/domains", servermock.ResponseFromFixture("domains-GetAll.json")). + Build(t) - mux.HandleFunc("/v1/domains", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/domains-GetAll.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - data, err := client.Domains.GetAll(context.Background(), nil) + data, err := client.Domains.GetAll(t.Context(), nil) require.NoError(t, err) expected := []Domain{ - {ID: 273301, Name: "aaa.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"}, - {ID: 273302, Name: "bbb.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"}, - {ID: 273303, Name: "ccc.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"}, - {ID: 273304, Name: "ddd.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"}, + {ID: 273301, Name: "aaa.example", TypeID: 1, Version: 9, Status: "ACTIVE"}, + {ID: 273302, Name: "bbb.example", TypeID: 1, Version: 9, Status: "ACTIVE"}, + {ID: 273303, Name: "ccc.example", TypeID: 1, Version: 9, Status: "ACTIVE"}, + {ID: 273304, Name: "ddd.example", TypeID: 1, Version: 9, Status: "ACTIVE"}, } assert.Equal(t, expected, data) } func TestDomainService_Search(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /v1/domains/search", + servermock.ResponseFromFixture("domains-Search.json"), + servermock.CheckQueryParameter().Strict(). + With("exact", "example.com")). + Build(t) - mux.HandleFunc("/v1/domains/search", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/domains-Search.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - data, err := client.Domains.Search(context.Background(), Exact, "lego.wtf") + data, err := client.Domains.Search(t.Context(), Exact, "example.com") require.NoError(t, err) expected := []Domain{ - {ID: 273302, Name: "lego.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"}, + {ID: 273302, Name: "example.com", TypeID: 1, Version: 9, Status: "ACTIVE"}, } assert.Equal(t, expected, data) diff --git a/providers/dns/constellix/internal/fixtures/domains-GetAll.json b/providers/dns/constellix/internal/fixtures/domains-GetAll.json index 5ff2ad41d..8ccb4e52c 100644 --- a/providers/dns/constellix/internal/fixtures/domains-GetAll.json +++ b/providers/dns/constellix/internal/fixtures/domains-GetAll.json @@ -1,7 +1,7 @@ [ { "id": 273301, - "name": "aaa.wtf", + "name": "aaa.example", "soa": { "primaryNameserver": "ns11.constellix.com.", "email": "dns.constellix.com.", @@ -36,7 +36,7 @@ }, { "id": 273302, - "name": "bbb.wtf", + "name": "bbb.example", "soa": { "primaryNameserver": "ns11.constellix.com.", "email": "dns.constellix.com.", @@ -71,7 +71,7 @@ }, { "id": 273303, - "name": "ccc.wtf", + "name": "ccc.example", "soa": { "primaryNameserver": "ns11.constellix.com.", "email": "dns.constellix.com.", @@ -106,7 +106,7 @@ }, { "id": 273304, - "name": "ddd.wtf", + "name": "ddd.example", "soa": { "primaryNameserver": "ns11.constellix.com.", "email": "dns.constellix.com.", diff --git a/providers/dns/constellix/internal/fixtures/domains-Search.json b/providers/dns/constellix/internal/fixtures/domains-Search.json index 5d018a39a..c33272515 100644 --- a/providers/dns/constellix/internal/fixtures/domains-Search.json +++ b/providers/dns/constellix/internal/fixtures/domains-Search.json @@ -1,7 +1,7 @@ [ { "id": 273302, - "name": "lego.wtf", + "name": "example.com", "soa": { "primaryNameserver": "ns11.constellix.com.", "email": "dns.constellix.com.", diff --git a/providers/dns/constellix/internal/txtrecords.go b/providers/dns/constellix/internal/txtrecords.go index 7880da4d2..bd00d84b7 100644 --- a/providers/dns/constellix/internal/txtrecords.go +++ b/providers/dns/constellix/internal/txtrecords.go @@ -32,6 +32,7 @@ func (s *TxtRecordService) Create(ctx context.Context, domainID int64, record Re } var records []Record + err = s.client.do(req, &records) if err != nil { return nil, err @@ -54,6 +55,7 @@ func (s *TxtRecordService) GetAll(ctx context.Context, domainID int64) ([]Record } var records []Record + err = s.client.do(req, &records) if err != nil { return nil, err @@ -76,6 +78,7 @@ func (s *TxtRecordService) Get(ctx context.Context, domainID, recordID int64) (* } var records Record + err = s.client.do(req, &records) if err != nil { return nil, err @@ -103,6 +106,7 @@ func (s *TxtRecordService) Update(ctx context.Context, domainID, recordID int64, } var msg SuccessMessage + err = s.client.do(req, &msg) if err != nil { return nil, err @@ -125,6 +129,7 @@ func (s *TxtRecordService) Delete(ctx context.Context, domainID, recordID int64) } var msg *SuccessMessage + err = s.client.do(req, &msg) if err != nil { return nil, err diff --git a/providers/dns/constellix/internal/txtrecords_test.go b/providers/dns/constellix/internal/txtrecords_test.go index 7adc4af5c..54d10dc38 100644 --- a/providers/dns/constellix/internal/txtrecords_test.go +++ b/providers/dns/constellix/internal/txtrecords_test.go @@ -1,41 +1,22 @@ package internal import ( - "context" "encoding/json" - "io" - "net/http" "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestTxtRecordService_Create(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("POST /v1/domains/12345/records/txt", servermock.ResponseFromFixture("records-Create.json"), + servermock.CheckRequestJSONBody(`{"name":""}`)). + Build(t) - mux.HandleFunc("/v1/domains/12345/records/txt", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/records-Create.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - records, err := client.TxtRecords.Create(context.Background(), 12345, RecordRequest{}) + records, err := client.TxtRecords.Create(t.Context(), 12345, RecordRequest{}) require.NoError(t, err) recordsJSON, err := json.Marshal(records) @@ -48,29 +29,11 @@ func TestTxtRecordService_Create(t *testing.T) { } func TestTxtRecordService_GetAll(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /v1/domains/12345/records/txt", servermock.ResponseFromFixture("records-GetAll.json")). + Build(t) - mux.HandleFunc("/v1/domains/12345/records/txt", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/records-GetAll.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - records, err := client.TxtRecords.GetAll(context.Background(), 12345) + records, err := client.TxtRecords.GetAll(t.Context(), 12345) require.NoError(t, err) recordsJSON, err := json.Marshal(records) @@ -83,29 +46,11 @@ func TestTxtRecordService_GetAll(t *testing.T) { } func TestTxtRecordService_Get(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /v1/domains/12345/records/txt/6789", servermock.ResponseFromFixture("records-Get.json")). + Build(t) - mux.HandleFunc("/v1/domains/12345/records/txt/6789", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/records-Get.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - record, err := client.TxtRecords.Get(context.Background(), 12345, 6789) + record, err := client.TxtRecords.Get(t.Context(), 12345, 6789) require.NoError(t, err) expected := &Record{ @@ -131,22 +76,12 @@ func TestTxtRecordService_Get(t *testing.T) { } func TestTxtRecordService_Update(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("PUT /v1/domains/12345/records/txt/6789", + servermock.RawStringResponse(`{"success":"Record updated successfully"}`)). + Build(t) - mux.HandleFunc("/v1/domains/12345/records/txt/6789", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPut { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - _, err := rw.Write([]byte(`{"success":"Record updated successfully"}`)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - msg, err := client.TxtRecords.Update(context.Background(), 12345, 6789, RecordRequest{}) + msg, err := client.TxtRecords.Update(t.Context(), 12345, 6789, RecordRequest{}) require.NoError(t, err) expected := &SuccessMessage{Success: "Record updated successfully"} @@ -154,22 +89,12 @@ func TestTxtRecordService_Update(t *testing.T) { } func TestTxtRecordService_Delete(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /v1/domains/12345/records/txt/6789", + servermock.RawStringResponse(`{"success":"Record deleted successfully"}`)). + Build(t) - mux.HandleFunc("/v1/domains/12345/records/txt/6789", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - _, err := rw.Write([]byte(`{"success":"Record deleted successfully"}`)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - msg, err := client.TxtRecords.Delete(context.Background(), 12345, 6789) + msg, err := client.TxtRecords.Delete(t.Context(), 12345, 6789) require.NoError(t, err) expected := &SuccessMessage{Success: "Record deleted successfully"} @@ -177,29 +102,11 @@ func TestTxtRecordService_Delete(t *testing.T) { } func TestTxtRecordService_Search(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /v1/domains/12345/records/txt/search", servermock.ResponseFromFixture("records-Search.json")). + Build(t) - mux.HandleFunc("/v1/domains/12345/records/txt/search", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/records-Search.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - records, err := client.TxtRecords.Search(context.Background(), 12345, Exact, "test") + records, err := client.TxtRecords.Search(t.Context(), 12345, Exact, "test") require.NoError(t, err) recordsJSON, err := json.Marshal(records) diff --git a/providers/dns/corenetworks/corenetworks.go b/providers/dns/corenetworks/corenetworks.go index 43b2f47b9..cde58a2bf 100644 --- a/providers/dns/corenetworks/corenetworks.go +++ b/providers/dns/corenetworks/corenetworks.go @@ -7,9 +7,11 @@ import ( "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/corenetworks/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -26,6 +28,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Login string @@ -87,6 +91,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } diff --git a/providers/dns/corenetworks/corenetworks.toml b/providers/dns/corenetworks/corenetworks.toml index f2bae017c..09840bb1b 100644 --- a/providers/dns/corenetworks/corenetworks.toml +++ b/providers/dns/corenetworks/corenetworks.toml @@ -7,7 +7,7 @@ Since = "v4.20.0" Example = ''' CORENETWORKS_LOGIN="xxxx" \ CORENETWORKS_PASSWORD="yyyy" \ -lego --email you@example.com --dns corenetworks -d '*.example.com' -d example.com run +lego --dns corenetworks -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,11 +15,11 @@ lego --email you@example.com --dns corenetworks -d '*.example.com' -d example.co CORENETWORKS_LOGIN = "The username of the API account" CORENETWORKS_PASSWORD = "The password" [Configuration.Additional] - CORENETWORKS_POLLING_INTERVAL = "Time between DNS propagation check" - CORENETWORKS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - CORENETWORKS_TTL = "The TTL of the TXT record used for the DNS challenge" - CORENETWORKS_HTTP_TIMEOUT = "API request timeout" - CORENETWORKS_SEQUENCE_INTERVAL = "Time between sequential requests" + CORENETWORKS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + CORENETWORKS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + CORENETWORKS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" + CORENETWORKS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + CORENETWORKS_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" [Links] API = "https://beta.api.core-networks.de/doc/" diff --git a/providers/dns/corenetworks/corenetworks_test.go b/providers/dns/corenetworks/corenetworks_test.go index 3cd80f88d..911693468 100644 --- a/providers/dns/corenetworks/corenetworks_test.go +++ b/providers/dns/corenetworks/corenetworks_test.go @@ -43,6 +43,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -111,6 +112,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -124,6 +126,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/corenetworks/internal/client.go b/providers/dns/corenetworks/internal/client.go index 993b01f1e..bdc17f2c1 100644 --- a/providers/dns/corenetworks/internal/client.go +++ b/providers/dns/corenetworks/internal/client.go @@ -38,7 +38,7 @@ func NewClient(login, password string) *Client { // ListZone gets a list of all DNS zones. // https://beta.api.core-networks.de/doc/#functon_dnszones -func (c Client) ListZone(ctx context.Context) ([]Zone, error) { +func (c *Client) ListZone(ctx context.Context) ([]Zone, error) { endpoint := c.baseURL.JoinPath("dnszones") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -47,6 +47,7 @@ func (c Client) ListZone(ctx context.Context) ([]Zone, error) { } var zones []Zone + err = c.do(req, &zones) if err != nil { return nil, err @@ -57,7 +58,7 @@ func (c Client) ListZone(ctx context.Context) ([]Zone, error) { // GetZoneDetails provides detailed information about a DNS zone. // https://beta.api.core-networks.de/doc/#functon_dnszones_details -func (c Client) GetZoneDetails(ctx context.Context, zone string) (*ZoneDetails, error) { +func (c *Client) GetZoneDetails(ctx context.Context, zone string) (*ZoneDetails, error) { endpoint := c.baseURL.JoinPath("dnszones", zone) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -66,6 +67,7 @@ func (c Client) GetZoneDetails(ctx context.Context, zone string) (*ZoneDetails, } var details ZoneDetails + err = c.do(req, &details) if err != nil { return nil, err @@ -76,7 +78,7 @@ func (c Client) GetZoneDetails(ctx context.Context, zone string) (*ZoneDetails, // ListRecords gets a list of DNS records belonging to the zone. // https://beta.api.core-networks.de/doc/#functon_dnszones_records -func (c Client) ListRecords(ctx context.Context, zone string) ([]Record, error) { +func (c *Client) ListRecords(ctx context.Context, zone string) ([]Record, error) { endpoint := c.baseURL.JoinPath("dnszones", zone, "records") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -85,6 +87,7 @@ func (c Client) ListRecords(ctx context.Context, zone string) ([]Record, error) } var records []Record + err = c.do(req, &records) if err != nil { return nil, err @@ -95,7 +98,7 @@ func (c Client) ListRecords(ctx context.Context, zone string) ([]Record, error) // AddRecord adds a record. // https://beta.api.core-networks.de/doc/#functon_dnszones_records_add -func (c Client) AddRecord(ctx context.Context, zone string, record Record) error { +func (c *Client) AddRecord(ctx context.Context, zone string, record Record) error { endpoint := c.baseURL.JoinPath("dnszones", zone, "records", "/") if record.Name == "" { @@ -117,7 +120,7 @@ func (c Client) AddRecord(ctx context.Context, zone string, record Record) error // DeleteRecords deletes all DNS records of a zone that match the DNS record passed. // https://beta.api.core-networks.de/doc/#functon_dnszones_records_delete -func (c Client) DeleteRecords(ctx context.Context, zone string, record Record) error { +func (c *Client) DeleteRecords(ctx context.Context, zone string, record Record) error { endpoint := c.baseURL.JoinPath("dnszones", zone, "records", "delete") if record.Name == "" { @@ -139,7 +142,7 @@ func (c Client) DeleteRecords(ctx context.Context, zone string, record Record) e // CommitRecords sends a commit to the zone. // https://beta.api.core-networks.de/doc/#functon_dnszones_commit -func (c Client) CommitRecords(ctx context.Context, zone string) error { +func (c *Client) CommitRecords(ctx context.Context, zone string) error { endpoint := c.baseURL.JoinPath("dnszones", zone, "records", "commit") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, nil) @@ -155,7 +158,7 @@ func (c Client) CommitRecords(ctx context.Context, zone string) error { return nil } -func (c Client) do(req *http.Request, result any) error { +func (c *Client) do(req *http.Request, result any) error { at := getToken(req.Context()) if at != "" { req.Header.Set(authorizationHeader, "Bearer "+at) diff --git a/providers/dns/corenetworks/internal/client_test.go b/providers/dns/corenetworks/internal/client_test.go index 0fff0d5ae..ca5c81a65 100644 --- a/providers/dns/corenetworks/internal/client_test.go +++ b/providers/dns/corenetworks/internal/client_test.go @@ -1,115 +1,36 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("user", "secret") - client.baseURL, _ = url.Parse(server.URL) - client.HTTPClient = server.Client() - - return client, mux -} - -func testHandler(method string, statusCode int, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf(`unsupported method: %s`, req.Method), http.StatusMethodNotAllowed) - return - } - - rw.WriteHeader(statusCode) - - if statusCode == http.StatusNoContent { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, fmt.Sprintf(`message %v`, err), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, fmt.Sprintf(`message %v`, err), http.StatusInternalServerError) - return - } - } -} - -func testHandlerAuth(method string, statusCode int, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) - return - } - - rw.WriteHeader(statusCode) - - if statusCode == http.StatusNoContent { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - } -} - -func TestClient_CreateAuthenticationToken(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/auth/token", testHandlerAuth(http.MethodPost, http.StatusOK, "auth.json")) - - ctx := context.Background() - - token, err := client.CreateAuthenticationToken(ctx) - require.NoError(t, err) - - expected := &Token{ - Token: "authsecret", - Expires: 123, - } - assert.Equal(t, expected, token) + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestClient_ListZone(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /dnszones/", + servermock.ResponseFromFixture("ListZone.json")). + Build(t) - mux.HandleFunc("/dnszones/", testHandler(http.MethodGet, http.StatusOK, "ListZone.json")) - - ctx := context.Background() + ctx := t.Context() zones, err := client.ListZone(ctx) require.NoError(t, err) @@ -123,13 +44,12 @@ func TestClient_ListZone(t *testing.T) { } func TestClient_GetZoneDetails(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /dnszones/example.com", + servermock.ResponseFromFixture("GetZoneDetails.json")). + Build(t) - mux.HandleFunc("/dnszones/example.com", testHandler(http.MethodGet, http.StatusOK, "GetZoneDetails.json")) - - ctx := context.Background() - - zone, err := client.GetZoneDetails(ctx, "example.com") + zone, err := client.GetZoneDetails(t.Context(), "example.com") require.NoError(t, err) expected := &ZoneDetails{ @@ -143,13 +63,12 @@ func TestClient_GetZoneDetails(t *testing.T) { } func TestClient_ListRecords(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /dnszones/example.com/records/", + servermock.ResponseFromFixture("ListRecords.json")). + Build(t) - mux.HandleFunc("/dnszones/example.com/records/", testHandler(http.MethodGet, http.StatusOK, "ListRecords.json")) - - ctx := context.Background() - - records, err := client.ListRecords(ctx, "example.com") + records, err := client.ListRecords(t.Context(), "example.com") require.NoError(t, err) expected := []Record{ @@ -177,38 +96,35 @@ func TestClient_ListRecords(t *testing.T) { } func TestClient_AddRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/dnszones/example.com/records/", testHandler(http.MethodPost, http.StatusNoContent, "")) - - ctx := context.Background() + client := mockBuilder(). + Route("POST /dnszones/example.com/records/", + servermock.Noop().WithStatusCode(http.StatusNoContent)). + Build(t) record := Record{Name: "www", TTL: 3600, Type: "A", Data: "127.0.0.1"} - err := client.AddRecord(ctx, "example.com", record) + err := client.AddRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_DeleteRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/dnszones/example.com/records/delete", testHandler(http.MethodPost, http.StatusNoContent, "")) - - ctx := context.Background() + client := mockBuilder(). + Route("POST /dnszones/example.com/records/delete", + servermock.Noop().WithStatusCode(http.StatusNoContent)). + Build(t) record := Record{Name: "www", Type: "A", Data: "127.0.0.1"} - err := client.DeleteRecords(ctx, "example.com", record) + err := client.DeleteRecords(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_CommitRecords(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("POST /dnszones/example.com/records/commit", + servermock.Noop().WithStatusCode(http.StatusNoContent)). + Build(t) - mux.HandleFunc("/dnszones/example.com/records/commit", testHandler(http.MethodPost, http.StatusNoContent, "")) - - ctx := context.Background() - - err := client.CommitRecords(ctx, "example.com") + err := client.CommitRecords(t.Context(), "example.com") require.NoError(t, err) } diff --git a/providers/dns/corenetworks/internal/identity.go b/providers/dns/corenetworks/internal/identity.go index 6a3b4d46a..a7e7448c0 100644 --- a/providers/dns/corenetworks/internal/identity.go +++ b/providers/dns/corenetworks/internal/identity.go @@ -13,7 +13,7 @@ const tokenKey token = "token" // CreateAuthenticationToken gets an authentication token. // https://beta.api.core-networks.de/doc/#functon_auth_token -func (c Client) CreateAuthenticationToken(ctx context.Context) (*Token, error) { +func (c *Client) CreateAuthenticationToken(ctx context.Context) (*Token, error) { endpoint := c.baseURL.JoinPath("auth", "token") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, Auth{Login: c.login, Password: c.password}) @@ -22,6 +22,7 @@ func (c Client) CreateAuthenticationToken(ctx context.Context) (*Token, error) { } var token Token + err = c.do(req, &token) if err != nil { return nil, err @@ -30,7 +31,7 @@ func (c Client) CreateAuthenticationToken(ctx context.Context) (*Token, error) { return &token, nil } -func (c Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) { +func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) { tok, err := c.CreateAuthenticationToken(ctx) if err != nil { return nil, err diff --git a/providers/dns/corenetworks/internal/identity_test.go b/providers/dns/corenetworks/internal/identity_test.go new file mode 100644 index 000000000..b5e05ed3f --- /dev/null +++ b/providers/dns/corenetworks/internal/identity_test.go @@ -0,0 +1,24 @@ +package internal + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_CreateAuthenticationToken(t *testing.T) { + client := mockBuilder(). + Route("POST /auth/token", servermock.ResponseFromFixture("auth.json")). + Build(t) + + token, err := client.CreateAuthenticationToken(t.Context()) + require.NoError(t, err) + + expected := &Token{ + Token: "authsecret", + Expires: 123, + } + assert.Equal(t, expected, token) +} diff --git a/providers/dns/cpanel/cpanel.go b/providers/dns/cpanel/cpanel.go index bb025c2a3..f335c0a8c 100644 --- a/providers/dns/cpanel/cpanel.go +++ b/providers/dns/cpanel/cpanel.go @@ -11,11 +11,13 @@ import ( "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/cpanel/internal/cpanel" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/whm" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -33,6 +35,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + type apiClient interface { FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) AddRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) @@ -143,12 +147,16 @@ func (d *DNSProvider) Present(domain, _, keyAuth string) error { valueB64 := base64.StdEncoding.EncodeToString([]byte(info.Value)) - var found bool - var existingRecord shared.ZoneRecord + var ( + found bool + existingRecord shared.ZoneRecord + ) + for _, record := range zoneInfo { if slices.Contains(record.DataB64, valueB64) { existingRecord = record found = true + break } } @@ -217,12 +225,16 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { valueB64 := base64.StdEncoding.EncodeToString([]byte(info.Value)) - var found bool - var existingRecord shared.ZoneRecord + var ( + found bool + existingRecord shared.ZoneRecord + ) + for _, record := range zoneInfo { if slices.Contains(record.DataB64, valueB64) { existingRecord = record found = true + break } } @@ -232,6 +244,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { } var newData []string + for _, dataB64 := range existingRecord.DataB64 { if dataB64 == valueB64 { continue @@ -288,6 +301,7 @@ func getZoneSerial(zoneFqdn string, zoneInfo []shared.ZoneRecord) (uint32, error } var newSerial uint32 + _, err = fmt.Sscan(string(data), &newSerial) if err != nil { return 0, fmt.Errorf("decode serial DNameB64, invalid serial value %q: %w", string(data), err) @@ -311,6 +325,8 @@ func createClient(config *Config) (apiClient, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return client, nil case "whm": @@ -323,6 +339,8 @@ func createClient(config *Config) (apiClient, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return client, nil default: diff --git a/providers/dns/cpanel/cpanel.toml b/providers/dns/cpanel/cpanel.toml index 10f75b385..b64adf0cf 100644 --- a/providers/dns/cpanel/cpanel.toml +++ b/providers/dns/cpanel/cpanel.toml @@ -7,18 +7,18 @@ Since = "v4.16.0" Example = ''' ### CPANEL (default) -CPANEL_USERNAME = "yyyy" -CPANEL_TOKEN = "xxxx" -CPANEL_BASE_URL = "https://example.com:2083" \ -lego --email you@example.com --dns cpanel -d '*.example.com' -d example.com run +CPANEL_USERNAME="yyyy" \ +CPANEL_TOKEN="xxxx" \ +CPANEL_BASE_URL="https://example.com:2083" \ +lego --dns cpanel -d '*.example.com' -d example.com run ## WHM -CPANEL_MODE = whm -CPANEL_USERNAME = "yyyy" -CPANEL_TOKEN = "xxxx" -CPANEL_BASE_URL = "https://example.com:2087" \ -lego --email you@example.com --dns cpanel -d '*.example.com' -d example.com run +CPANEL_MODE=whm \ +CPANEL_USERNAME="yyyy" \ +CPANEL_TOKEN="xxxx" \ +CPANEL_BASE_URL="https://example.com:2087" \ +lego --dns cpanel -d '*.example.com' -d example.com run ''' [Configuration] @@ -28,11 +28,10 @@ lego --email you@example.com --dns cpanel -d '*.example.com' -d example.com run CPANEL_BASE_URL = "API server URL" [Configuration.Additional] CPANEL_MODE = "use cpanel API or WHM API (Default: cpanel)" - CPANEL_POLLING_INTERVAL = "Time between DNS propagation check" - CPANEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - CPANEL_TTL = "The TTL of the TXT record used for the DNS challenge" - CPANEL_HTTP_TIMEOUT = "API request timeout" - CPANEL_REGION = "The region" + CPANEL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + CPANEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + CPANEL_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + CPANEL_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API_CPANEL = "https://api.docs.cpanel.net/cpanel/introduction/" diff --git a/providers/dns/cpanel/cpanel_test.go b/providers/dns/cpanel/cpanel_test.go index 614b9e1c7..5d85b8b5b 100644 --- a/providers/dns/cpanel/cpanel_test.go +++ b/providers/dns/cpanel/cpanel_test.go @@ -75,6 +75,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -282,6 +283,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -295,6 +297,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/cpanel/internal/cpanel/client.go b/providers/dns/cpanel/internal/cpanel/client.go index 3bca6b521..e869f6f4b 100644 --- a/providers/dns/cpanel/internal/cpanel/client.go +++ b/providers/dns/cpanel/internal/cpanel/client.go @@ -24,7 +24,7 @@ type Client struct { HTTPClient *http.Client } -func NewClient(baseURL string, username string, token string) (*Client, error) { +func NewClient(baseURL, username, token string) (*Client, error) { apiEndpoint, err := url.Parse(baseURL) if err != nil { return nil, err @@ -40,7 +40,7 @@ func NewClient(baseURL string, username string, token string) (*Client, error) { // FetchZoneInformation fetches zone information. // https://api.docs.cpanel.net/openapi/cpanel/operation/dns-parse_zone/ -func (c Client) FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) { +func (c *Client) FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) { endpoint := c.baseURL.JoinPath("DNS", "parse_zone") query := endpoint.Query() @@ -64,7 +64,7 @@ func (c Client) FetchZoneInformation(ctx context.Context, domain string) ([]shar // AddRecord adds a new record. // // add='{"dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}' -func (c Client) AddRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { +func (c *Client) AddRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { data, err := json.Marshal(record) if err != nil { return nil, fmt.Errorf("failed to create request JSON data: %w", err) @@ -76,7 +76,7 @@ func (c Client) AddRecord(ctx context.Context, serial uint32, domain string, rec // EditRecord edits an existing record. // // edit='{"line_index": 9, "dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}' -func (c Client) EditRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { +func (c *Client) EditRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { data, err := json.Marshal(record) if err != nil { return nil, fmt.Errorf("failed to create request JSON data: %w", err) @@ -88,12 +88,12 @@ func (c Client) EditRecord(ctx context.Context, serial uint32, domain string, re // DeleteRecord deletes an existing record. // // remove=22 -func (c Client) DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error) { +func (c *Client) DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error) { return c.updateZone(ctx, serial, domain, "remove", strconv.Itoa(lineIndex)) } // https://api.docs.cpanel.net/openapi/cpanel/operation/dns-mass_edit_zone/ -func (c Client) updateZone(ctx context.Context, serial uint32, domain, action, data string) (*shared.ZoneSerial, error) { +func (c *Client) updateZone(ctx context.Context, serial uint32, domain, action, data string) (*shared.ZoneSerial, error) { endpoint := c.baseURL.JoinPath("DNS", "mass_edit_zone") query := endpoint.Query() @@ -116,7 +116,7 @@ func (c Client) updateZone(ctx context.Context, serial uint32, domain, action, d return &result.Data, nil } -func (c Client) doRequest(ctx context.Context, endpoint *url.URL, result any) error { +func (c *Client) doRequest(ctx context.Context, endpoint *url.URL, result any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) if err != nil { return fmt.Errorf("unable to create request: %w", err) diff --git a/providers/dns/cpanel/internal/cpanel/client_test.go b/providers/dns/cpanel/internal/cpanel/client_test.go index 8516259d6..533d1130d 100644 --- a/providers/dns/cpanel/internal/cpanel/client_test.go +++ b/providers/dns/cpanel/internal/cpanel/client_test.go @@ -1,61 +1,40 @@ package cpanel import ( - "context" - "fmt" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "user", "secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - open, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(http.StatusOK) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient(server.URL, "user", "secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("cpanel user:secret")) } func TestClient_FetchZoneInformation(t *testing.T) { - client := setupTest(t, "/execute/DNS/parse_zone", "zone-info.json") + client := mockBuilder(). + Route("GET /execute/DNS/parse_zone", + servermock.ResponseFromFixture("zone-info.json"), + servermock.CheckQueryParameter().Strict(). + With("zone", "example.com")). + Build(t) - zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com") + zoneInfo, err := client.FetchZoneInformation(t.Context(), "example.com") require.NoError(t, err) expected := []shared.ZoneRecord{{ @@ -71,16 +50,27 @@ func TestClient_FetchZoneInformation(t *testing.T) { } func TestClient_FetchZoneInformation_error(t *testing.T) { - client := setupTest(t, "/execute/DNS/parse_zone", "zone-info_error.json") + client := mockBuilder(). + Route("GET /execute/DNS/parse_zone", + servermock.ResponseFromFixture("zone-info_error.json")). + Build(t) - zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com") - require.Error(t, err) + zoneInfo, err := client.FetchZoneInformation(t.Context(), "example.com") + require.EqualError(t, err, "error(0): You do not control a DNS zone named example.com.: a, b, c") assert.Nil(t, zoneInfo) } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone.json") + client := mockBuilder(). + Route("GET /execute/DNS/mass_edit_zone", + servermock.ResponseFromFixture("update-zone.json"), + servermock.CheckQueryParameter().Strict(). + With("zone", "example.com"). + With("add", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"]}`). + With("serial", "123456"). + With("zone", "example.com")). + Build(t) record := shared.Record{ DName: "example", @@ -89,7 +79,7 @@ func TestClient_AddRecord(t *testing.T) { Data: []string{"string1", "string2"}, } - zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record) + zoneSerial, err := client.AddRecord(t.Context(), 123456, "example.com", record) require.NoError(t, err) expected := &shared.ZoneSerial{NewSerial: "2021031903"} @@ -98,7 +88,10 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone_error.json") + client := mockBuilder(). + Route("GET /execute/DNS/mass_edit_zone", + servermock.ResponseFromFixture("update-zone_error.json")). + Build(t) record := shared.Record{ DName: "example", @@ -107,14 +100,21 @@ func TestClient_AddRecord_error(t *testing.T) { Data: []string{"string1", "string2"}, } - zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record) + zoneSerial, err := client.AddRecord(t.Context(), 123456, "example.com", record) require.Error(t, err) assert.Nil(t, zoneSerial) } func TestClient_EditRecord(t *testing.T) { - client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone.json") + client := mockBuilder(). + Route("GET /execute/DNS/mass_edit_zone", + servermock.ResponseFromFixture("update-zone.json"), + servermock.CheckQueryParameter().Strict(). + With("edit", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"],"line_index":9}`). + With("serial", "123456"). + With("zone", "example.com")). + Build(t) record := shared.Record{ LineIndex: 9, @@ -124,7 +124,7 @@ func TestClient_EditRecord(t *testing.T) { Data: []string{"string1", "string2"}, } - zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record) + zoneSerial, err := client.EditRecord(t.Context(), 123456, "example.com", record) require.NoError(t, err) expected := &shared.ZoneSerial{NewSerial: "2021031903"} @@ -133,7 +133,10 @@ func TestClient_EditRecord(t *testing.T) { } func TestClient_EditRecord_error(t *testing.T) { - client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone_error.json") + client := mockBuilder(). + Route("GET /execute/DNS/mass_edit_zone", + servermock.ResponseFromFixture("update-zone_error.json")). + Build(t) record := shared.Record{ LineIndex: 9, @@ -143,16 +146,23 @@ func TestClient_EditRecord_error(t *testing.T) { Data: []string{"string1", "string2"}, } - zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record) + zoneSerial, err := client.EditRecord(t.Context(), 123456, "example.com", record) require.Error(t, err) assert.Nil(t, zoneSerial) } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone.json") + client := mockBuilder(). + Route("GET /execute/DNS/mass_edit_zone", + servermock.ResponseFromFixture("update-zone.json"), + servermock.CheckQueryParameter().Strict(). + With("remove", "0"). + With("serial", "123456"). + With("zone", "example.com")). + Build(t) - zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0) + zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0) require.NoError(t, err) expected := &shared.ZoneSerial{NewSerial: "2021031903"} @@ -161,9 +171,12 @@ func TestClient_DeleteRecord(t *testing.T) { } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone_error.json") + client := mockBuilder(). + Route("GET /execute/DNS/mass_edit_zone", + servermock.ResponseFromFixture("update-zone_error.json")). + Build(t) - zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0) + zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0) require.Error(t, err) assert.Nil(t, zoneSerial) diff --git a/providers/dns/cpanel/internal/cpanel/types.go b/providers/dns/cpanel/internal/cpanel/types.go index cb4dbd535..0a3053647 100644 --- a/providers/dns/cpanel/internal/cpanel/types.go +++ b/providers/dns/cpanel/internal/cpanel/types.go @@ -6,7 +6,7 @@ import ( ) type APIResponse[T any] struct { - Metadata Metadata `json:"metadata,omitempty"` + Metadata Metadata `json:"metadata"` Data T `json:"data,omitempty"` Status int `json:"status,omitempty"` diff --git a/providers/dns/cpanel/internal/whm/client.go b/providers/dns/cpanel/internal/whm/client.go index d375b83e3..742b25b6a 100644 --- a/providers/dns/cpanel/internal/whm/client.go +++ b/providers/dns/cpanel/internal/whm/client.go @@ -24,7 +24,7 @@ type Client struct { HTTPClient *http.Client } -func NewClient(baseURL string, username string, token string) (*Client, error) { +func NewClient(baseURL, username, token string) (*Client, error) { apiEndpoint, err := url.Parse(baseURL) if err != nil { return nil, err @@ -40,7 +40,7 @@ func NewClient(baseURL string, username string, token string) (*Client, error) { // FetchZoneInformation fetches zone information. // https://api.docs.cpanel.net/openapi/whm/operation/parse_dns_zone/ -func (c Client) FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) { +func (c *Client) FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) { endpoint := c.baseURL.JoinPath("parse_dns_zone") query := endpoint.Query() @@ -64,7 +64,7 @@ func (c Client) FetchZoneInformation(ctx context.Context, domain string) ([]shar // AddRecord adds a new record. // // add='{"dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}' -func (c Client) AddRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { +func (c *Client) AddRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { data, err := json.Marshal(record) if err != nil { return nil, fmt.Errorf("failed to create request JSON data: %w", err) @@ -76,7 +76,7 @@ func (c Client) AddRecord(ctx context.Context, serial uint32, domain string, rec // EditRecord edits an existing record. // // edit='{"line_index": 9, "dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}' -func (c Client) EditRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { +func (c *Client) EditRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { data, err := json.Marshal(record) if err != nil { return nil, fmt.Errorf("failed to create request JSON data: %w", err) @@ -88,12 +88,12 @@ func (c Client) EditRecord(ctx context.Context, serial uint32, domain string, re // DeleteRecord deletes an existing record. // // remove=22 -func (c Client) DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error) { +func (c *Client) DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error) { return c.updateZone(ctx, serial, domain, "remove", strconv.Itoa(lineIndex)) } // https://api.docs.cpanel.net/openapi/whm/operation/mass_edit_dns_zone/ -func (c Client) updateZone(ctx context.Context, serial uint32, domain, action, data string) (*shared.ZoneSerial, error) { +func (c *Client) updateZone(ctx context.Context, serial uint32, domain, action, data string) (*shared.ZoneSerial, error) { endpoint := c.baseURL.JoinPath("mass_edit_dns_zone") query := endpoint.Query() @@ -116,7 +116,7 @@ func (c Client) updateZone(ctx context.Context, serial uint32, domain, action, d return &result.Data, nil } -func (c Client) doRequest(ctx context.Context, endpoint *url.URL, result any) error { +func (c *Client) doRequest(ctx context.Context, endpoint *url.URL, result any) error { query := endpoint.Query() query.Set("api.version", "1") endpoint.RawQuery = query.Encode() diff --git a/providers/dns/cpanel/internal/whm/client_test.go b/providers/dns/cpanel/internal/whm/client_test.go index f4f6d7b19..47686bf09 100644 --- a/providers/dns/cpanel/internal/whm/client_test.go +++ b/providers/dns/cpanel/internal/whm/client_test.go @@ -1,61 +1,41 @@ package whm import ( - "context" - "fmt" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "user", "secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - open, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(http.StatusOK) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient(server.URL, "user", "secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("whm user:secret")) } func TestClient_FetchZoneInformation(t *testing.T) { - client := setupTest(t, "/json-api/parse_dns_zone", "zone-info.json") + client := mockBuilder(). + Route("GET /json-api/parse_dns_zone", + servermock.ResponseFromFixture("zone-info.json"), + servermock.CheckQueryParameter().Strict(). + With("api.version", "1"). + With("zone", "example.com")). + Build(t) - zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com") + zoneInfo, err := client.FetchZoneInformation(t.Context(), "example.com") require.NoError(t, err) expected := []shared.ZoneRecord{{ @@ -71,16 +51,27 @@ func TestClient_FetchZoneInformation(t *testing.T) { } func TestClient_FetchZoneInformation_error(t *testing.T) { - client := setupTest(t, "/json-api/parse_dns_zone", "zone-info_error.json") + client := mockBuilder(). + Route("GET /json-api/parse_dns_zone", + servermock.ResponseFromFixture("zone-info_error.json")). + Build(t) - zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com") + zoneInfo, err := client.FetchZoneInformation(t.Context(), "example.com") require.Error(t, err) assert.Nil(t, zoneInfo) } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone.json") + client := mockBuilder(). + Route("GET /json-api/mass_edit_dns_zone", + servermock.ResponseFromFixture("update-zone.json"), + servermock.CheckQueryParameter().Strict(). + With("add", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"]}`). + With("api.version", "1"). + With("serial", "123456"). + With("zone", "example.com")). + Build(t) record := shared.Record{ DName: "example", @@ -89,7 +80,7 @@ func TestClient_AddRecord(t *testing.T) { Data: []string{"string1", "string2"}, } - zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record) + zoneSerial, err := client.AddRecord(t.Context(), 123456, "example.com", record) require.NoError(t, err) expected := &shared.ZoneSerial{NewSerial: "2021031903"} @@ -98,7 +89,10 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone_error.json") + client := mockBuilder(). + Route("GET /json-api/mass_edit_dns_zone", + servermock.ResponseFromFixture("update-zone_error.json")). + Build(t) record := shared.Record{ DName: "example", @@ -107,14 +101,22 @@ func TestClient_AddRecord_error(t *testing.T) { Data: []string{"string1", "string2"}, } - zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record) + zoneSerial, err := client.AddRecord(t.Context(), 123456, "example.com", record) require.Error(t, err) assert.Nil(t, zoneSerial) } func TestClient_EditRecord(t *testing.T) { - client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone.json") + client := mockBuilder(). + Route("GET /json-api/mass_edit_dns_zone", + servermock.ResponseFromFixture("update-zone.json"), + servermock.CheckQueryParameter().Strict(). + With("edit", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"],"line_index":9}`). + With("api.version", "1"). + With("serial", "123456"). + With("zone", "example.com")). + Build(t) record := shared.Record{ LineIndex: 9, @@ -124,7 +126,7 @@ func TestClient_EditRecord(t *testing.T) { Data: []string{"string1", "string2"}, } - zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record) + zoneSerial, err := client.EditRecord(t.Context(), 123456, "example.com", record) require.NoError(t, err) expected := &shared.ZoneSerial{NewSerial: "2021031903"} @@ -133,7 +135,10 @@ func TestClient_EditRecord(t *testing.T) { } func TestClient_EditRecord_error(t *testing.T) { - client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone_error.json") + client := mockBuilder(). + Route("GET /json-api/mass_edit_dns_zone", + servermock.ResponseFromFixture("update-zone_error.json")). + Build(t) record := shared.Record{ LineIndex: 9, @@ -143,16 +148,24 @@ func TestClient_EditRecord_error(t *testing.T) { Data: []string{"string1", "string2"}, } - zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record) + zoneSerial, err := client.EditRecord(t.Context(), 123456, "example.com", record) require.Error(t, err) assert.Nil(t, zoneSerial) } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone.json") + client := mockBuilder(). + Route("GET /json-api/mass_edit_dns_zone", + servermock.ResponseFromFixture("update-zone.json"), + servermock.CheckQueryParameter().Strict(). + With("remove", "0"). + With("api.version", "1"). + With("serial", "123456"). + With("zone", "example.com")). + Build(t) - zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0) + zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0) require.NoError(t, err) expected := &shared.ZoneSerial{NewSerial: "2021031903"} @@ -161,9 +174,12 @@ func TestClient_DeleteRecord(t *testing.T) { } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone_error.json") + client := mockBuilder(). + Route("GET /json-api/mass_edit_dns_zone", + servermock.ResponseFromFixture("update-zone_error.json")). + Build(t) - zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0) + zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0) require.Error(t, err) assert.Nil(t, zoneSerial) diff --git a/providers/dns/cpanel/internal/whm/types.go b/providers/dns/cpanel/internal/whm/types.go index f1884a04d..d0604a565 100644 --- a/providers/dns/cpanel/internal/whm/types.go +++ b/providers/dns/cpanel/internal/whm/types.go @@ -7,7 +7,7 @@ import ( ) type APIResponse[T any] struct { - Metadata Metadata `json:"metadata,omitempty"` + Metadata Metadata `json:"metadata"` Data T `json:"data,omitempty"` } diff --git a/providers/dns/czechia/czechia.go b/providers/dns/czechia/czechia.go new file mode 100644 index 000000000..3ff397c35 --- /dev/null +++ b/providers/dns/czechia/czechia.go @@ -0,0 +1,159 @@ +// Package czechia implements a DNS provider for solving the DNS-01 challenge using Czechia. +package czechia + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/czechia/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "CZECHIA_" + + EnvToken = envNamespace + "TOKEN" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Token string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Czechia. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvToken) + if err != nil { + return nil, fmt.Errorf("czechia: %w", err) + } + + config := NewDefaultConfig() + config.Token = values[EnvToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Czechia. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("czechia: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.Token) + if err != nil { + return nil, fmt.Errorf("czechia: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("czechia: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("czechia: %w", err) + } + + record := internal.TXTRecord{ + Hostname: subDomain, + Text: info.Value, + TTL: d.config.TTL, + PublishZone: 1, + } + + err = d.client.AddTXTRecord(ctx, dns01.UnFqdn(authZone), record) + if err != nil { + return fmt.Errorf("czechia: add TXT record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("czechia: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("czechia: %w", err) + } + + record := internal.TXTRecord{ + Hostname: subDomain, + Text: info.Value, + TTL: d.config.TTL, + PublishZone: 1, + } + + err = d.client.DeleteTXTRecord(ctx, dns01.UnFqdn(authZone), record) + if err != nil { + return fmt.Errorf("czechia: delete TXT record: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/czechia/czechia.toml b/providers/dns/czechia/czechia.toml new file mode 100644 index 000000000..2a66d2054 --- /dev/null +++ b/providers/dns/czechia/czechia.toml @@ -0,0 +1,22 @@ +Name = "Czechia" +Description = '''''' +URL = "https://www.czechia.com/" +Code = "czechia" +Since = "v4.33.0" + +Example = ''' +CZECHIA_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns czechia -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + CZECHIA_TOKEN = "Authorization token" + [Configuration.Additional] + CZECHIA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + CZECHIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + CZECHIA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + CZECHIA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://api.czechia.com/swagger/index.html" diff --git a/providers/dns/czechia/czechia_test.go b/providers/dns/czechia/czechia_test.go new file mode 100644 index 000000000..7d9a2676c --- /dev/null +++ b/providers/dns/czechia/czechia_test.go @@ -0,0 +1,165 @@ +package czechia + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvToken: "secret", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "czechia: some credentials information are missing: CZECHIA_TOKEN", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + token string + expected string + }{ + { + desc: "success", + token: "secret", + }, + { + desc: "missing credentials", + expected: "czechia: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Token = test.token + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Token = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With("AuthorizationToken", "secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("POST /DNS/example.com/TXT", + servermock.Noop(), + servermock.CheckRequestJSONBodyFromInternal("add_txt_record-request.json"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("DELETE /DNS/example.com/TXT", + servermock.Noop(), + servermock.CheckRequestJSONBodyFromInternal("add_txt_record-request.json"), + ). + Build(t) + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/czechia/internal/client.go b/providers/dns/czechia/internal/client.go new file mode 100644 index 000000000..f3e0e462e --- /dev/null +++ b/providers/dns/czechia/internal/client.go @@ -0,0 +1,124 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://api.czechia.com/api" + +const authorizationTokenHeader = "AuthorizationToken" + +// Client the Czechia API client. +type Client struct { + token string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(token string) (*Client, error) { + if token == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + token: token, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) AddTXTRecord(ctx context.Context, domain string, record TXTRecord) error { + endpoint := c.BaseURL.JoinPath("DNS", domain, "TXT") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) DeleteTXTRecord(ctx context.Context, domain string, record TXTRecord) error { + endpoint := c.BaseURL.JoinPath("DNS", domain, "TXT") + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, record) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) do(req *http.Request, result any) error { + useragent.SetHeader(req.Header) + + req.Header.Set(authorizationTokenHeader, c.token) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + raw, _ := io.ReadAll(resp.Body) + + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} diff --git a/providers/dns/czechia/internal/client_test.go b/providers/dns/czechia/internal/client_test.go new file mode 100644 index 000000000..c6f1141c5 --- /dev/null +++ b/providers/dns/czechia/internal/client_test.go @@ -0,0 +1,67 @@ +package internal + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With(authorizationTokenHeader, "secret"), + ) +} + +func TestClient_AddTXTRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /DNS/example.com/TXT", + servermock.Noop(), + servermock.CheckRequestJSONBodyFromFixture("add_txt_record-request.json"), + ). + Build(t) + + record := TXTRecord{ + Hostname: "_acme-challenge", + Text: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 120, + PublishZone: 1, + } + + err := client.AddTXTRecord(t.Context(), "example.com", record) + require.NoError(t, err) +} + +func TestClient_DeleteTXTRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /DNS/example.com/TXT", + servermock.Noop(), + servermock.CheckRequestJSONBodyFromFixture("add_txt_record-request.json"), + ). + Build(t) + + record := TXTRecord{ + Hostname: "_acme-challenge", + Text: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 120, + PublishZone: 1, + } + + err := client.DeleteTXTRecord(t.Context(), "example.com", record) + require.NoError(t, err) +} diff --git a/providers/dns/czechia/internal/fixtures/add_txt_record-request.json b/providers/dns/czechia/internal/fixtures/add_txt_record-request.json new file mode 100644 index 000000000..ed5830093 --- /dev/null +++ b/providers/dns/czechia/internal/fixtures/add_txt_record-request.json @@ -0,0 +1,6 @@ +{ + "hostName": "_acme-challenge", + "text": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 120, + "publishZone": 1 +} diff --git a/providers/dns/czechia/internal/fixtures/delete_txt_record-request.json b/providers/dns/czechia/internal/fixtures/delete_txt_record-request.json new file mode 100644 index 000000000..ed5830093 --- /dev/null +++ b/providers/dns/czechia/internal/fixtures/delete_txt_record-request.json @@ -0,0 +1,6 @@ +{ + "hostName": "_acme-challenge", + "text": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 120, + "publishZone": 1 +} diff --git a/providers/dns/czechia/internal/types.go b/providers/dns/czechia/internal/types.go new file mode 100644 index 000000000..f4a9bfef7 --- /dev/null +++ b/providers/dns/czechia/internal/types.go @@ -0,0 +1,8 @@ +package internal + +type TXTRecord struct { + Hostname string `json:"hostName,omitempty"` + Text string `json:"text,omitempty"` + TTL int `json:"ttl,omitempty"` + PublishZone int `json:"publishZone,omitempty"` +} diff --git a/providers/dns/ddnss/ddnss.go b/providers/dns/ddnss/ddnss.go new file mode 100644 index 000000000..381151c55 --- /dev/null +++ b/providers/dns/ddnss/ddnss.go @@ -0,0 +1,130 @@ +// Package ddnss implements a DNS provider for solving the DNS-01 challenge using DynDNS Service. +package ddnss + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/ddnss/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "DDNSS_" + + EnvKey = envNamespace + "KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" + EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Key string + + PropagationTimeout time.Duration + PollingInterval time.Duration + SequenceInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for DynDNS Service. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvKey) + if err != nil { + return nil, fmt.Errorf("ddnss: %w", err) + } + + config := NewDefaultConfig() + config.Key = values[EnvKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for DynDNS Service. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("ddnss: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(&internal.Authentication{Key: config.Key}) + if err != nil { + return nil, fmt.Errorf("ddnss: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + err := d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value) + if err != nil { + return fmt.Errorf("ddnss: add TXT record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + err := d.client.RemoveTXTRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("ddnss: remove TXT record: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Sequential All DNS challenges for this provider will be resolved sequentially. +// Returns the interval between each iteration. +func (d *DNSProvider) Sequential() time.Duration { + return d.config.SequenceInterval +} diff --git a/providers/dns/ddnss/ddnss.toml b/providers/dns/ddnss/ddnss.toml new file mode 100644 index 000000000..0d0a7132c --- /dev/null +++ b/providers/dns/ddnss/ddnss.toml @@ -0,0 +1,23 @@ +Name = "DDnss (DynDNS Service)" +Description = '''''' +URL = "https://ddnss.de/" +Code = "ddnss" +Since = "v4.32.0" + +Example = ''' +DDNSS_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns ddnss -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + DDNSS_KEY = "Update key" + [Configuration.Additional] + DDNSS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + DDNSS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + DDNSS_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" + DDNSS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + DDNSS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://ddnss.de/info.php" diff --git a/providers/dns/ddnss/ddnss_test.go b/providers/dns/ddnss/ddnss_test.go new file mode 100644 index 000000000..5b1d7df58 --- /dev/null +++ b/providers/dns/ddnss/ddnss_test.go @@ -0,0 +1,168 @@ +package ddnss + +import ( + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvKey: "secret", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "ddnss: some credentials information are missing: DDNSS_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + Key string + expected string + }{ + { + desc: "success", + Key: "secret", + }, + { + desc: "missing credentials", + expected: "ddnss: missing credentials", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Key = test.Key + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Key = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL = server.URL + + return p, nil + }, + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("GET /", + servermock.ResponseFromInternal("success.html"), + servermock.CheckQueryParameter().Strict(). + With("host", "_acme-challenge.example.com"). + With("key", "secret"). + With("txt", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"). + With("txtm", "1"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("GET /", + servermock.ResponseFromInternal("success.html"), + servermock.CheckQueryParameter().Strict(). + With("host", "_acme-challenge.example.com"). + With("key", "secret"). + With("txtm", "2"), + ). + Build(t) + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/ddnss/internal/client.go b/providers/dns/ddnss/internal/client.go new file mode 100644 index 000000000..a0cf4b4a6 --- /dev/null +++ b/providers/dns/ddnss/internal/client.go @@ -0,0 +1,137 @@ +package internal + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" + "golang.org/x/net/html" +) + +const defaultBaseURL = "https://ddnss.de/upd.php" + +// Client the DDns API client. +type Client struct { + auth *Authentication + + BaseURL string + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(auth *Authentication) (*Client, error) { + if auth == nil { + return nil, errors.New("credentials missing") + } + + err := auth.validate() + if err != nil { + return nil, err + } + + return &Client{ + auth: auth, + BaseURL: defaultBaseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) AddTXTRecord(ctx context.Context, host, value string) error { + return c.update(ctx, map[string]string{ + "host": host, + "txt": value, + "txtm": "1", + }) +} + +func (c *Client) RemoveTXTRecord(ctx context.Context, host string) error { + return c.update(ctx, map[string]string{ + "host": host, + "txtm": "2", + }) +} + +func (c *Client) update(ctx context.Context, params map[string]string) error { + endpoint, err := url.Parse(c.BaseURL) + if err != nil { + return err + } + + query := endpoint.Query() + + for k, v := range params { + query.Set(k, v) + } + + c.auth.set(query) + + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return fmt.Errorf("unable to create request: %w", err) + } + + useragent.SetHeader(req.Header) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + raw, _ := io.ReadAll(resp.Body) + + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + content, err := readPage(raw) + if err != nil { + return err + } + + if strings.Contains(content, "Updated 1 hostname.") { + return nil + } + + return fmt.Errorf("unexpected response: %s", content) +} + +func readPage(raw []byte) (string, error) { + page, err := html.Parse(strings.NewReader(string(raw))) + if err != nil { + return "", err + } + + var b strings.Builder + extractText(page, &b) + + return strings.TrimSpace(b.String()), nil +} + +func extractText(n *html.Node, b *strings.Builder) { + if n.Type == html.TextNode { + text := strings.TrimSpace(n.Data) + if text != "" { + b.WriteString(text + " ") + } + } + + for c := n.FirstChild; c != nil; c = c.NextSibling { + extractText(c, b) + } +} diff --git a/providers/dns/ddnss/internal/client_test.go b/providers/dns/ddnss/internal/client_test.go new file mode 100644 index 000000000..3faddded0 --- /dev/null +++ b/providers/dns/ddnss/internal/client_test.go @@ -0,0 +1,56 @@ +package internal + +import ( + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(&Authentication{Key: "secret"}) + if err != nil { + return nil, err + } + + client.BaseURL = server.URL + client.HTTPClient = server.Client() + + return client, nil + }, + ) +} + +func TestClient_AddTXTRecord(t *testing.T) { + client := mockBuilder(). + Route("GET /", + servermock.ResponseFromFixture("success.html"), + servermock.CheckQueryParameter().Strict(). + With("host", "_acme-challenge.example.com"). + With("key", "secret"). + With("txt", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"). + With("txtm", "1"), + ). + Build(t) + + err := client.AddTXTRecord(t.Context(), "_acme-challenge.example.com", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY") + require.NoError(t, err) +} + +func TestClient_RemoveTXTRecord(t *testing.T) { + client := mockBuilder(). + Route("GET /", + servermock.ResponseFromFixture("success.html"), + servermock.CheckQueryParameter().Strict(). + With("host", "_acme-challenge.example.com"). + With("key", "secret"). + With("txtm", "2"), + ). + Build(t) + + err := client.RemoveTXTRecord(t.Context(), "_acme-challenge.example.com") + require.NoError(t, err) +} diff --git a/providers/dns/ddnss/internal/fixtures/error.html b/providers/dns/ddnss/internal/fixtures/error.html new file mode 100644 index 000000000..f0599ad9a --- /dev/null +++ b/providers/dns/ddnss/internal/fixtures/error.html @@ -0,0 +1,12 @@ + + + DDNSS - Kostenloser DynDNS Service : Re-ProutDNS v5.01v + + +

+

Error Occurred While Processing Request :

+
+ - badysys : Der System Parameter ist ungültig.
+ - badauth : Die Authorisation ist fehlgeschlagen. Die Parameter username und/oder password sind falsch.
+ - notfqdn : Hostname fehlt oder ist falsch.
+ diff --git a/providers/dns/ddnss/internal/fixtures/success.html b/providers/dns/ddnss/internal/fixtures/success.html new file mode 100644 index 000000000..f51957334 --- /dev/null +++ b/providers/dns/ddnss/internal/fixtures/success.html @@ -0,0 +1,8 @@ + + + DDNSS - Kostenloser DynDNS Service : Re-ProutDNS v5.01v + + +

+

Updated 1 hostname.

+ diff --git a/providers/dns/ddnss/internal/types.go b/providers/dns/ddnss/internal/types.go new file mode 100644 index 000000000..37d41e076 --- /dev/null +++ b/providers/dns/ddnss/internal/types.go @@ -0,0 +1,39 @@ +package internal + +import ( + "errors" + "net/url" +) + +type Authentication struct { + Username string `url:"user,omitempty"` + Password string `url:"pwd,omitempty"` + Key string `url:"key,omitempty"` +} + +func (a *Authentication) validate() error { + if a.Username == "" && a.Password == "" && a.Key == "" { + return errors.New("missing credentials") + } + + if a.Username != "" && a.Password != "" && a.Key != "" { + return errors.New("only one of username, password or key can be set") + } + + if (a.Username != "" && a.Password == "") || a.Username == "" && a.Password != "" { + return errors.New("username and password must be set together") + } + + return nil +} + +func (a *Authentication) set(query url.Values) { + if a.Key != "" { + query.Set("key", a.Key) + + return + } + + query.Set("user", a.Username) + query.Set("pwd", a.Password) +} diff --git a/providers/dns/derak/derak.go b/providers/dns/derak/derak.go index 28262fb04..78165b936 100644 --- a/providers/dns/derak/derak.go +++ b/providers/dns/derak/derak.go @@ -10,9 +10,11 @@ import ( "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/derak/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/miekg/dns" ) @@ -29,6 +31,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -91,6 +95,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -157,6 +163,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("derak: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } diff --git a/providers/dns/derak/derak.toml b/providers/dns/derak/derak.toml index 202d20834..72f49883a 100644 --- a/providers/dns/derak/derak.toml +++ b/providers/dns/derak/derak.toml @@ -6,7 +6,7 @@ Since = "v4.12.0" Example = ''' DERAK_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ -lego --email you@example.com --dns derak -d '*.example.com' -d example.com run +lego --dns derak -d '*.example.com' -d example.com run ''' [Configuration] @@ -14,7 +14,7 @@ lego --email you@example.com --dns derak -d '*.example.com' -d example.com run DERAK_API_KEY = "The API key" [Configuration.Additional] DERAK_WEBSITE_ID = "Force the zone/website ID" - DERAK_POLLING_INTERVAL = "Time between DNS propagation check" - DERAK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - DERAK_TTL = "The TTL of the TXT record used for the DNS challenge" - DERAK_HTTP_TIMEOUT = "API request timeout" + DERAK_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" + DERAK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + DERAK_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + DERAK_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" diff --git a/providers/dns/derak/derak_test.go b/providers/dns/derak/derak_test.go index e58cfb6c1..b83eb2c8c 100644 --- a/providers/dns/derak/derak_test.go +++ b/providers/dns/derak/derak_test.go @@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -92,6 +93,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -105,6 +107,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/derak/internal/client.go b/providers/dns/derak/internal/client.go index 3e7c76fdb..4352e198b 100644 --- a/providers/dns/derak/internal/client.go +++ b/providers/dns/derak/internal/client.go @@ -37,13 +37,14 @@ func NewClient(apiKey string) *Client { // GetRecords gets all records. // Note: the response is not influenced by the query parameters, so the documentation seems wrong. -func (c Client) GetRecords(ctx context.Context, zoneID string, params *GetRecordsParameters) (*GetRecordsResponse, error) { +func (c *Client) GetRecords(ctx context.Context, zoneID string, params *GetRecordsParameters) (*GetRecordsResponse, error) { endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords") v, err := querystring.Values(params) if err != nil { return nil, err } + endpoint.RawQuery = v.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -52,6 +53,7 @@ func (c Client) GetRecords(ctx context.Context, zoneID string, params *GetRecord } response := &GetRecordsResponse{} + err = c.do(req, response) if err != nil { return nil, err @@ -61,7 +63,7 @@ func (c Client) GetRecords(ctx context.Context, zoneID string, params *GetRecord } // GetRecord gets a record by ID. -func (c Client) GetRecord(ctx context.Context, zoneID string, recordID string) (*Record, error) { +func (c *Client) GetRecord(ctx context.Context, zoneID, recordID string) (*Record, error) { endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords", recordID) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -70,6 +72,7 @@ func (c Client) GetRecord(ctx context.Context, zoneID string, recordID string) ( } response := &Record{} + err = c.do(req, response) if err != nil { return nil, err @@ -79,7 +82,7 @@ func (c Client) GetRecord(ctx context.Context, zoneID string, recordID string) ( } // CreateRecord creates a new record. -func (c Client) CreateRecord(ctx context.Context, zoneID string, record Record) (*Record, error) { +func (c *Client) CreateRecord(ctx context.Context, zoneID string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords") req, err := newJSONRequest(ctx, http.MethodPut, endpoint, record) @@ -88,6 +91,7 @@ func (c Client) CreateRecord(ctx context.Context, zoneID string, record Record) } response := &Record{} + err = c.do(req, response) if err != nil { return nil, err @@ -97,7 +101,7 @@ func (c Client) CreateRecord(ctx context.Context, zoneID string, record Record) } // EditRecord edits an existing record. -func (c Client) EditRecord(ctx context.Context, zoneID string, recordID string, record Record) (*Record, error) { +func (c *Client) EditRecord(ctx context.Context, zoneID, recordID string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords", recordID) req, err := newJSONRequest(ctx, http.MethodPatch, endpoint, record) @@ -106,6 +110,7 @@ func (c Client) EditRecord(ctx context.Context, zoneID string, recordID string, } response := &Record{} + err = c.do(req, response) if err != nil { return nil, err @@ -115,7 +120,7 @@ func (c Client) EditRecord(ctx context.Context, zoneID string, recordID string, } // DeleteRecord deletes an existing record. -func (c Client) DeleteRecord(ctx context.Context, zoneID string, recordID string) error { +func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error { endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) @@ -140,13 +145,14 @@ func (c Client) DeleteRecord(ctx context.Context, zoneID string, recordID string // GetZones gets zones. // Note: it's not a part of the official API, there is no documentation about this. // The endpoint comes from UI calls analysis. -func (c Client) GetZones(ctx context.Context) ([]Zone, error) { +func (c *Client) GetZones(ctx context.Context) ([]Zone, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.zoneEndpoint, http.NoBody) if err != nil { return nil, err } response := &APIResponse[[]Zone]{} + err = c.do(req, response) if err != nil { return nil, err @@ -159,7 +165,7 @@ func (c Client) GetZones(ctx context.Context) ([]Zone, error) { return response.Result, nil } -func (c Client) do(req *http.Request, result any) error { +func (c *Client) do(req *http.Request, result any) error { req.SetBasicAuth("api", c.apiKey) resp, err := c.HTTPClient.Do(req) @@ -221,6 +227,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response APIResponse[any] + err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/derak/internal/client_test.go b/providers/dns/derak/internal/client_test.go index 3d542e4a7..322a7f48c 100644 --- a/providers/dns/derak/internal/client_test.go +++ b/providers/dns/derak/internal/client_test.go @@ -1,83 +1,39 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" "time" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.baseURL, _ = url.Parse(server.URL) client.zoneEndpoint = server.URL client.HTTPClient = server.Client() - return client, mux + return client, nil } -func testHandler(method string, statusCode int, filename string) func(rw http.ResponseWriter, req *http.Request) { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - username, password, ok := req.BasicAuth() - if !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if username != "api" { - http.Error(rw, fmt.Sprintf("username: want %s got %s", username, "user"), http.StatusUnauthorized) - return - } - - if password != "secret" { - http.Error(rw, fmt.Sprintf("password: want %s got %s", password, "secret"), http.StatusUnauthorized) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("api", "secret")) } func TestGetRecords(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", + servermock.ResponseFromFixture("records-GET.json")). + Build(t) - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", - testHandler(http.MethodGet, http.StatusOK, "records-GET.json")) - - records, err := client.GetRecords(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", &GetRecordsParameters{DNSType: "TXT", Content: `"test"'`}) + records, err := client.GetRecords(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", &GetRecordsParameters{DNSType: "TXT", Content: `"test"'`}) require.NoError(t, err) excepted := &GetRecordsResponse{Data: []Record{ @@ -135,22 +91,23 @@ func TestGetRecords(t *testing.T) { } func TestGetRecords_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", - testHandler(http.MethodGet, http.StatusUnauthorized, "error.json")) - - _, err := client.GetRecords(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", &GetRecordsParameters{DNSType: "TXT", Content: `"test"'`}) + _, err := client.GetRecords(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", &GetRecordsParameters{DNSType: "TXT", Content: `"test"'`}) require.Error(t, err) } func TestGetRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/812bee17a0b440b0bd5ee099a78b839c", + servermock.ResponseFromFixture("record-GET.json")). + Build(t) - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/812bee17a0b440b0bd5ee099a78b839c", - testHandler(http.MethodGet, http.StatusOK, "record-GET.json")) - - record, err := client.GetRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", "812bee17a0b440b0bd5ee099a78b839c") + record, err := client.GetRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "812bee17a0b440b0bd5ee099a78b839c") require.NoError(t, err) excepted := &Record{ @@ -164,20 +121,22 @@ func TestGetRecord(t *testing.T) { } func TestGetRecord_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/812bee17a0b440b0bd5ee099a78b839c", - testHandler(http.MethodGet, http.StatusUnauthorized, "error.json")) - - _, err := client.GetRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", "812bee17a0b440b0bd5ee099a78b839c") + _, err := client.GetRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "812bee17a0b440b0bd5ee099a78b839c") require.Error(t, err) } func TestCreateRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", - testHandler(http.MethodPut, http.StatusCreated, "record-PUT.json")) + client := mockBuilder(). + Route("PUT /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", + servermock.ResponseFromFixture("record-PUT.json"). + WithStatusCode(http.StatusCreated)). + Build(t) r := Record{ Type: "TXT", @@ -186,7 +145,7 @@ func TestCreateRecord(t *testing.T) { TTL: 120, } - record, err := client.CreateRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", r) + record, err := client.CreateRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", r) require.NoError(t, err) excepted := &Record{ @@ -200,10 +159,11 @@ func TestCreateRecord(t *testing.T) { } func TestCreateRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", - testHandler(http.MethodPut, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("PUT /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) r := Record{ Type: "TXT", @@ -212,17 +172,17 @@ func TestCreateRecord_error(t *testing.T) { TTL: 120, } - _, err := client.CreateRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", r) + _, err := client.CreateRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", r) require.Error(t, err) } func TestEditRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("PATCH /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2", + servermock.ResponseFromFixture("record-PATCH.json")). + Build(t) - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2", - testHandler(http.MethodPatch, http.StatusOK, "record-PATCH.json")) - - record, err := client.EditRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", "eebc813de2f94d67b09d91e10e2d65c2", Record{ + record, err := client.EditRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "eebc813de2f94d67b09d91e10e2d65c2", Record{ Content: "foo", }) require.NoError(t, err) @@ -238,43 +198,48 @@ func TestEditRecord(t *testing.T) { } func TestEditRecord_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("PATCH /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2", - testHandler(http.MethodPatch, http.StatusUnauthorized, "error.json")) - - _, err := client.EditRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", "eebc813de2f94d67b09d91e10e2d65c2", Record{ + _, err := client.EditRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "eebc813de2f94d67b09d91e10e2d65c2", Record{ Content: "foo", }) require.Error(t, err) } func TestDeleteRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df", + servermock.ResponseFromFixture("record-DELETE.json")). + Build(t) - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df", - testHandler(http.MethodDelete, http.StatusOK, "record-DELETE.json")) - - err := client.DeleteRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", "653464211b7447a1bee6b8fcb9fb86df") + err := client.DeleteRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "653464211b7447a1bee6b8fcb9fb86df") require.NoError(t, err) } func TestDeleteRecord_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df", - testHandler(http.MethodDelete, http.StatusUnauthorized, "error.json")) - - err := client.DeleteRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", "653464211b7447a1bee6b8fcb9fb86df") + err := client.DeleteRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "653464211b7447a1bee6b8fcb9fb86df") require.Error(t, err) } func TestGetZones(t *testing.T) { - client, mux := setupTest(t) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader(). + WithBasicAuth("api", "secret"), + ). + Route("GET /", servermock.ResponseFromFixture("service-cdn-zones.json")). + Build(t) - mux.HandleFunc("/", testHandler(http.MethodGet, http.StatusOK, "service-cdn-zones.json")) - - zones, err := client.GetZones(context.Background()) + zones, err := client.GetZones(t.Context()) require.NoError(t, err) excepted := []Zone{{ @@ -303,10 +268,11 @@ func TestGetZones(t *testing.T) { } func TestGetZones_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /", servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - mux.HandleFunc("/", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json")) - - _, err := client.GetZones(context.Background()) + _, err := client.GetZones(t.Context()) require.Error(t, err) } diff --git a/providers/dns/derak/internal/types.go b/providers/dns/derak/internal/types.go index 15ed00617..02116314f 100644 --- a/providers/dns/derak/internal/types.go +++ b/providers/dns/derak/internal/types.go @@ -46,7 +46,7 @@ type Zone struct { HumanReadable string `json:"humanReadable,omitempty"` Serial string `json:"serial,omitempty"` CreationTime int64 `json:"creationTime,omitempty"` - CreationTimeDate time.Time `json:"creationTimeDate,omitempty"` + CreationTimeDate time.Time `json:"creationTimeDate,omitzero"` Status string `json:"status,omitempty"` IsMoved bool `json:"is_moved,omitempty"` Paused bool `json:"paused,omitempty"` diff --git a/providers/dns/desec/desec.go b/providers/dns/desec/desec.go index a8aee6ac1..9cc54f65e 100644 --- a/providers/dns/desec/desec.go +++ b/providers/dns/desec/desec.go @@ -9,8 +9,10 @@ import ( "net/http" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/nrdcg/desec" ) @@ -30,6 +32,8 @@ const ( // https://desec.readthedocs.io/_/downloads/en/latest/pdf/ const defaultTTL int = 3600 +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Token string @@ -85,6 +89,9 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config.HTTPClient != nil { opts.HTTPClient = config.HTTPClient } + + opts.HTTPClient = clientdebug.Wrap(opts.HTTPClient) + opts.Logger = log.Default() client := desec.New(config.Token, opts) @@ -173,6 +180,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } records := make([]string, 0) + for _, record := range rrSet.Records { if record != fmt.Sprintf(`%q`, info.Value) { records = append(records, record) diff --git a/providers/dns/desec/desec.toml b/providers/dns/desec/desec.toml index 6f5486027..f7e66ae07 100644 --- a/providers/dns/desec/desec.toml +++ b/providers/dns/desec/desec.toml @@ -6,17 +6,17 @@ Since = "v3.7.0" Example = ''' DESEC_TOKEN=x-xxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns desec -d '*.example.com' -d example.com run +lego --dns desec -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DESEC_TOKEN = "Domain token" [Configuration.Additional] - DESEC_POLLING_INTERVAL = "Time between DNS propagation check" - DESEC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - DESEC_TTL = "The TTL of the TXT record used for the DNS challenge" - DESEC_HTTP_TIMEOUT = "API request timeout" + DESEC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)" + DESEC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + DESEC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" + DESEC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://desec.readthedocs.io/en/latest/" diff --git a/providers/dns/desec/desec_test.go b/providers/dns/desec/desec_test.go index f91f9e82a..93d9bd010 100644 --- a/providers/dns/desec/desec_test.go +++ b/providers/dns/desec/desec_test.go @@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -93,6 +94,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -106,6 +108,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/designate/designate.go b/providers/dns/designate/designate.go index 8b712b5a4..41bf251f6 100644 --- a/providers/dns/designate/designate.go +++ b/providers/dns/designate/designate.go @@ -10,6 +10,7 @@ import ( "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/gophercloud/gophercloud" @@ -44,6 +45,8 @@ const ( EnvCloud = envNamespaceClient + "CLOUD" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { ZoneName string @@ -65,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 } @@ -82,7 +86,6 @@ func NewDNSProvider() (*DNSProvider, error) { opts, erro := clientconfig.AuthOptions(&clientconfig.ClientOpts{ Cloud: val[EnvCloud], }) - if erro != nil { return nil, fmt.Errorf("designate: %w", erro) } @@ -199,6 +202,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("designate: error for %s in CleanUp: %w", info.EffectiveFQDN, err) } + return nil } @@ -238,14 +242,20 @@ func (d *DNSProvider) updateRecord(record *recordsets.RecordSet, value string) e } result := recordsets.Update(d.client, record.ZoneID, record.ID, updateOpts) + return result.Err } func (d *DNSProvider) getZoneID(wanted string) (string, error) { - allPages, err := zones.List(d.client, nil).AllPages() + listOpts := zones.ListOpts{ + Name: wanted, + } + + allPages, err := zones.List(d.client, listOpts).AllPages() if err != nil { return "", err } + allZones, err := zones.ExtractZones(allPages) if err != nil { return "", err @@ -256,14 +266,21 @@ func (d *DNSProvider) getZoneID(wanted string) (string, error) { return zone.ID, nil } } + return "", fmt.Errorf("zone id not found for %s", wanted) } func (d *DNSProvider) getRecord(zoneID, wanted string) (*recordsets.RecordSet, error) { - allPages, err := recordsets.ListByZone(d.client, zoneID, nil).AllPages() + listOpts := recordsets.ListOpts{ + Name: wanted, + Type: "TXT", + } + + allPages, err := recordsets.ListByZone(d.client, zoneID, listOpts).AllPages() if err != nil { return nil, err } + allRecords, err := recordsets.ExtractRecordSets(allPages) if err != nil { return nil, err diff --git a/providers/dns/designate/designate.toml b/providers/dns/designate/designate.toml index aec11eb1e..a36034f64 100644 --- a/providers/dns/designate/designate.toml +++ b/providers/dns/designate/designate.toml @@ -7,7 +7,7 @@ Since = "v2.2.0" Example = ''' # With a `clouds.yaml` OS_CLOUD=my_openstack \ -lego --email you@example.com --dns designate -d '*.example.com' -d example.com run +lego --dns designate -d '*.example.com' -d example.com run # or @@ -16,7 +16,7 @@ OS_REGION_NAME=RegionOne \ OS_PROJECT_ID=23d4522a987d4ab529f722a007c27846 OS_USERNAME=myuser \ OS_PASSWORD=passw0rd \ -lego --email you@example.com --dns designate -d '*.example.com' -d example.com run +lego --dns designate -d '*.example.com' -d example.com run # or @@ -25,7 +25,7 @@ OS_REGION_NAME=RegionOne \ OS_AUTH_TYPE=v3applicationcredential \ OS_APPLICATION_CREDENTIAL_ID=imn74uq0or7dyzz20dwo1ytls4me8dry \ OS_APPLICATION_CREDENTIAL_SECRET=68FuSPSdQqkFQYH5X1OoriEIJOwyLtQ8QSqXZOc9XxFK1A9tzZT6He2PfPw0OMja \ -lego --email you@example.com --dns designate -d '*.example.com' -d example.com run +lego --dns designate -d '*.example.com' -d example.com run ''' Additional = ''' @@ -64,9 +64,9 @@ Public cloud providers with support for Designate: OS_PROJECT_ID = "Project ID" OS_TENANT_NAME = "Tenant name (deprecated see OS_PROJECT_NAME and OS_PROJECT_ID)" DESIGNATE_ZONE_NAME = "The zone name to use in the OpenStack Project to manage TXT records." - DESIGNATE_POLLING_INTERVAL = "Time between DNS propagation check" - DESIGNATE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - DESIGNATE_TTL = "The TTL of the TXT record used for the DNS challenge" + DESIGNATE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + DESIGNATE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)" + DESIGNATE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)" [Links] API = "https://docs.openstack.org/designate/latest/" diff --git a/providers/dns/designate/designate_test.go b/providers/dns/designate/designate_test.go index 881faeef1..e5edf81f8 100644 --- a/providers/dns/designate/designate_test.go +++ b/providers/dns/designate/designate_test.go @@ -105,6 +105,7 @@ func TestNewDNSProvider_fromEnv(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -192,6 +193,7 @@ func TestNewDNSProvider_fromCloud(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(map[string]string{ @@ -265,10 +267,10 @@ func TestNewDNSProviderConfig(t *testing.T) { func createCloudsYaml(t *testing.T, cloudName string, cloud clientconfig.Cloud) string { t.Helper() - file, err := os.CreateTemp("", "lego_test") + file, err := os.CreateTemp(t.TempDir(), "lego_test") require.NoError(t, err) - t.Cleanup(func() { _ = os.RemoveAll(file.Name()) }) + t.Cleanup(func() { _ = file.Close() }) clouds := clientconfig.Clouds{ Clouds: map[string]clientconfig.Cloud{ @@ -331,6 +333,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -344,6 +347,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/digitalocean/digitalocean.go b/providers/dns/digitalocean/digitalocean.go index 4ef8c061a..26c6fb9d4 100644 --- a/providers/dns/digitalocean/digitalocean.go +++ b/providers/dns/digitalocean/digitalocean.go @@ -10,9 +10,11 @@ import ( "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/digitalocean/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -28,6 +30,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string @@ -43,7 +47,7 @@ func NewDefaultConfig() *Config { return &Config{ BaseURL: env.GetOrDefaultString(EnvAPIUrl, internal.DefaultBaseURL), TTL: env.GetOrDefaultInt(EnvTTL, 30), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Second), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), @@ -85,10 +89,15 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("digitalocean: credentials missing") } - client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken)) + client := internal.NewClient( + clientdebug.Wrap( + internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken), + ), + ) if config.BaseURL != "" { var err error + client.BaseURL, err = url.Parse(config.BaseURL) if err != nil { return nil, fmt.Errorf("digitalocean: %w", err) @@ -144,6 +153,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("digitalocean: unknown record ID for '%s'", info.EffectiveFQDN) } diff --git a/providers/dns/digitalocean/digitalocean.toml b/providers/dns/digitalocean/digitalocean.toml index ef2e9de7c..8f9107c26 100644 --- a/providers/dns/digitalocean/digitalocean.toml +++ b/providers/dns/digitalocean/digitalocean.toml @@ -6,7 +6,7 @@ Since = "v0.3.0" Example = ''' DO_AUTH_TOKEN=xxxxxx \ -lego --email you@example.com --dns digitalocean -d '*.example.com' -d example.com run +lego --dns digitalocean -d '*.example.com' -d example.com run ''' [Configuration] @@ -14,10 +14,10 @@ lego --email you@example.com --dns digitalocean -d '*.example.com' -d example.co DO_AUTH_TOKEN = "Authentication token" [Configuration.Additional] DO_API_URL = "The URL of the API" - DO_POLLING_INTERVAL = "Time between DNS propagation check" - DO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - DO_TTL = "The TTL of the TXT record used for the DNS challenge" - DO_HTTP_TIMEOUT = "API request timeout" + DO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" + DO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + DO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)" + DO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.digitalocean.com/documentation/v2/#domain-records" diff --git a/providers/dns/digitalocean/digitalocean_test.go b/providers/dns/digitalocean/digitalocean_test.go index bfd2d68c0..d066e12db 100644 --- a/providers/dns/digitalocean/digitalocean_test.go +++ b/providers/dns/digitalocean/digitalocean_test.go @@ -1,36 +1,30 @@ package digitalocean import ( - "bytes" - "fmt" - "io" "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester" - "github.com/stretchr/testify/assert" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvAuthToken) -func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) { - t.Helper() +func mockProvider() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.AuthToken = "asdf1234" + config.BaseURL = server.URL + config.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - config := NewDefaultConfig() - config.AuthToken = "asdf1234" - config.BaseURL = server.URL - config.HTTPClient = server.Client() - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - return provider, mux + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With("Authorization", "Bearer asdf1234")) } func TestNewDNSProvider(t *testing.T) { @@ -57,6 +51,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -111,26 +106,9 @@ func TestNewDNSProviderConfig(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/v2/domains/example.com/records", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method, "method") - - assert.Equal(t, "application/json", r.Header.Get("Accept"), "Accept") - assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type") - assert.Equal(t, "Bearer asdf1234", r.Header.Get("Authorization"), "Authorization") - - reqBody, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - expectedReqBody := `{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}` - assert.Equal(t, expectedReqBody, string(bytes.TrimSpace(reqBody))) - - w.WriteHeader(http.StatusCreated) - _, err = fmt.Fprintf(w, `{ + provider := mockProvider(). + Route("POST /v2/domains/example.com/records", + servermock.RawStringResponse(`{ "domain_record": { "id": 1234567, "type": "TXT", @@ -140,36 +118,26 @@ func TestDNSProvider_Present(t *testing.T) { "port": null, "weight": null } - }`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + }`). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBody(`{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}`)). + Build(t) err := provider.Present("example.com", "", "foobar") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/v2/domains/example.com/records/1234567", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodDelete, r.Method, "method") - - assert.Equal(t, "/v2/domains/example.com/records/1234567", r.URL.Path, "Path") - - assert.Equal(t, "application/json", r.Header.Get("Accept"), "Accept") - assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type") - assert.Equal(t, "Bearer asdf1234", r.Header.Get("Authorization"), "Authorization") - - w.WriteHeader(http.StatusNoContent) - }) + provider := mockProvider(). + Route("DELETE /v2/domains/example.com/records/1234567", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) provider.recordIDsMu.Lock() provider.recordIDs["token"] = 1234567 provider.recordIDsMu.Unlock() err := provider.CleanUp("example.com", "token", "") - require.NoError(t, err, "fail to remove TXT record") + require.NoError(t, err) } diff --git a/providers/dns/digitalocean/internal/client.go b/providers/dns/digitalocean/internal/client.go index e7dd181b2..395de478c 100644 --- a/providers/dns/digitalocean/internal/client.go +++ b/providers/dns/digitalocean/internal/client.go @@ -45,6 +45,7 @@ func (c *Client) AddTxtRecord(ctx context.Context, zone string, record Record) ( } respData := &TxtRecordResponse{} + err = c.do(req, respData) if err != nil { return nil, err @@ -120,6 +121,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errInfo APIError + err := json.Unmarshal(raw, &errInfo) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/digitalocean/internal/client_test.go b/providers/dns/digitalocean/internal/client_test.go index 081e1a109..65ce5dfaa 100644 --- a/providers/dns/digitalocean/internal/client_test.go +++ b/providers/dns/digitalocean/internal/client_test.go @@ -1,95 +1,35 @@ package internal import ( - "bytes" - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) + client.BaseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) - client.BaseURL, _ = url.Parse(server.URL) - - mux.HandleFunc(pattern, handler) - - return client -} - -func checkHeader(req *http.Request, name, value string) error { - val := req.Header.Get(name) - if val != value { - return fmt.Errorf("invalid header value, got: %s want %s", val, value) - } - return nil -} - -func writeFixture(rw http.ResponseWriter, filename string) { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer secret")) } func TestClient_AddTxtRecord(t *testing.T) { - client := setupTest(t, "/v2/domains/example.com/records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - err := checkHeader(req, "Accept", "application/json") - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - err = checkHeader(req, "Content-Type", "application/json") - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - err = checkHeader(req, "Authorization", "Bearer secret") - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - reqBody, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - expectedReqBody := `{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}` - if expectedReqBody != string(bytes.TrimSpace(reqBody)) { - http.Error(rw, fmt.Sprintf("unexpected request body: %s", string(bytes.TrimSpace(reqBody))), http.StatusBadRequest) - return - } - - rw.WriteHeader(http.StatusCreated) - writeFixture(rw, "domains-records_POST.json") - }) + client := mockBuilder(). + Route("POST /v2/domains/example.com/records", + servermock.ResponseFromFixture("domains-records_POST.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBody(`{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}`)). + Build(t) record := Record{ Type: "TXT", @@ -98,7 +38,7 @@ func TestClient_AddTxtRecord(t *testing.T) { TTL: 30, } - newRecord, err := client.AddTxtRecord(context.Background(), "example.com", record) + newRecord, err := client.AddTxtRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &TxtRecordResponse{DomainRecord: Record{ @@ -113,27 +53,12 @@ func TestClient_AddTxtRecord(t *testing.T) { } func TestClient_RemoveTxtRecord(t *testing.T) { - client := setupTest(t, "/v2/domains/example.com/records/1234567", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } + client := mockBuilder(). + Route("DELETE /v2/domains/example.com/records/1234567", + servermock.ResponseFromFixture("domains-records_POST.json"). + WithStatusCode(http.StatusNoContent)). + Build(t) - err := checkHeader(req, "Accept", "application/json") - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - err = checkHeader(req, "Authorization", "Bearer secret") - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - rw.WriteHeader(http.StatusNoContent) - }) - - err := client.RemoveTxtRecord(context.Background(), "example.com", 1234567) + err := client.RemoveTxtRecord(t.Context(), "example.com", 1234567) require.NoError(t, err) } diff --git a/providers/dns/directadmin/directadmin.go b/providers/dns/directadmin/directadmin.go index b25aff818..8dfa132ae 100644 --- a/providers/dns/directadmin/directadmin.go +++ b/providers/dns/directadmin/directadmin.go @@ -7,9 +7,11 @@ import ( "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/directadmin/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -27,6 +29,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string @@ -96,9 +100,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{client: client, config: config}, nil } +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (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) diff --git a/providers/dns/directadmin/directadmin.toml b/providers/dns/directadmin/directadmin.toml index 6b9f1353f..294eaca1c 100644 --- a/providers/dns/directadmin/directadmin.toml +++ b/providers/dns/directadmin/directadmin.toml @@ -8,7 +8,7 @@ Example = ''' DIRECTADMIN_API_URL="http://example.com:2222" \ DIRECTADMIN_USERNAME=xxxx \ DIRECTADMIN_PASSWORD=yyy \ -lego --email you@example.com --dns directadmin -d '*.example.com' -d example.com run +lego --dns directadmin -d '*.example.com' -d example.com run ''' [Configuration] @@ -18,10 +18,10 @@ lego --email you@example.com --dns directadmin -d '*.example.com' -d example.com DIRECTADMIN_PASSWORD = "API password" [Configuration.Additional] DIRECTADMIN_ZONE_NAME = "Zone name used to add the TXT record" - DIRECTADMIN_POLLING_INTERVAL = "Time between DNS propagation check" - DIRECTADMIN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - DIRECTADMIN_TTL = "The TTL of the TXT record used for the DNS challenge" - DIRECTADMIN_HTTP_TIMEOUT = "API request timeout" + DIRECTADMIN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" + DIRECTADMIN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + DIRECTADMIN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)" + DIRECTADMIN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.directadmin.com/api.php" diff --git a/providers/dns/directadmin/directadmin_test.go b/providers/dns/directadmin/directadmin_test.go index 10c079f73..aed3ba505 100644 --- a/providers/dns/directadmin/directadmin_test.go +++ b/providers/dns/directadmin/directadmin_test.go @@ -59,6 +59,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -135,6 +136,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -148,6 +150,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/directadmin/internal/client.go b/providers/dns/directadmin/internal/client.go index fb84257bc..64409a79d 100644 --- a/providers/dns/directadmin/internal/client.go +++ b/providers/dns/directadmin/internal/client.go @@ -38,7 +38,7 @@ func NewClient(baseURL, username, password string) (*Client, error) { }, nil } -func (c Client) SetRecord(ctx context.Context, domain string, record Record) error { +func (c *Client) SetRecord(ctx context.Context, domain string, record Record) error { data, err := querystring.Values(record) if err != nil { return err @@ -49,7 +49,7 @@ func (c Client) SetRecord(ctx context.Context, domain string, record Record) err return c.do(ctx, domain, data) } -func (c Client) DeleteRecord(ctx context.Context, domain string, record Record) error { +func (c *Client) DeleteRecord(ctx context.Context, domain string, record Record) error { data, err := querystring.Values(record) if err != nil { return err @@ -60,7 +60,7 @@ func (c Client) DeleteRecord(ctx context.Context, domain string, record Record) return c.do(ctx, domain, data) } -func (c Client) do(ctx context.Context, domain string, data url.Values) error { +func (c *Client) do(ctx context.Context, domain string, data url.Values) error { endpoint := c.baseURL.JoinPath("CMD_API_DNS_CONTROL") query := endpoint.Query() @@ -94,6 +94,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errInfo APIError + err := json.Unmarshal(raw, &errInfo) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/directadmin/internal/client_test.go b/providers/dns/directadmin/internal/client_test.go index ded4769e3..759a7fb4e 100644 --- a/providers/dns/directadmin/internal/client_test.go +++ b/providers/dns/directadmin/internal/client_test.go @@ -1,89 +1,48 @@ package internal import ( - "context" - "encoding/json" "fmt" - "io" "net/http" "net/http/httptest" - "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, _ := NewClient(server.URL, "user", "secret") + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client, _ := NewClient(server.URL, "user", "secret") - client.HTTPClient = server.Client() - - return client, mux + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()) } -func newJSONErrorf(reason string, a ...any) string { - err := APIError{ +func newAPIError(reason string, a ...any) APIError { + return APIError{ Message: "Cannot View Dns Record", Result: fmt.Sprintf(reason, a...), } - - data, _ := json.Marshal(err) - - return string(data) -} - -func testHandler(kv map[string]string) func(rw http.ResponseWriter, req *http.Request) { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - domain := req.URL.Query().Get("domain") - if domain != "example.com" { - http.Error(rw, newJSONErrorf("invalid domain: %s", domain), http.StatusUnauthorized) - return - } - - data, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - values, err := url.ParseQuery(string(data)) - if err != nil { - http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - for k, v := range kv { - actual := values.Get(k) - if v != actual { - http.Error(rw, newJSONErrorf("invalid %q: %s", k, actual), http.StatusBadRequest) - return - } - } - } } func TestClient_SetRecord(t *testing.T) { - client, mux := setupTest(t) - - kv := map[string]string{ - "action": "add", - "name": "foo", - "type": "TXT", - "value": "txtTXTtxt", - "ttl": "123", - } - - mux.HandleFunc("/CMD_API_DNS_CONTROL", testHandler(kv)) + client := mockBuilder(). + Route("POST /CMD_API_DNS_CONTROL", nil, + servermock.CheckQueryParameter().Strict(). + With("domain", "example.com"). + With("json", "yes"), + servermock.CheckForm().UsePostForm().Strict(). + With("action", "add"). + With("name", "foo"). + With("type", "TXT"). + With("value", "txtTXTtxt"). + With("ttl", "123"), + ). + Build(t) record := Record{ Name: "foo", @@ -92,16 +51,16 @@ func TestClient_SetRecord(t *testing.T) { TTL: 123, } - err := client.SetRecord(context.Background(), "example.com", record) + err := client.SetRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_SetRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/CMD_API_DNS_CONTROL", func(rw http.ResponseWriter, req *http.Request) { - http.Error(rw, newJSONErrorf("OOPS"), http.StatusInternalServerError) - }) + client := mockBuilder(). + Route("POST /CMD_API_DNS_CONTROL", + servermock.JSONEncode(newAPIError("OOPS")). + WithStatusCode(http.StatusInternalServerError)). + Build(t) record := Record{ Name: "foo", @@ -110,22 +69,23 @@ func TestClient_SetRecord_error(t *testing.T) { TTL: 123, } - err := client.SetRecord(context.Background(), "example.com", record) + err := client.SetRecord(t.Context(), "example.com", record) require.EqualError(t, err, "[status code 500] Cannot View Dns Record: OOPS") } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) - - kv := map[string]string{ - "action": "delete", - "name": "foo", - "type": "TXT", - "value": "txtTXTtxt", - "ttl": "", - } - - mux.HandleFunc("/CMD_API_DNS_CONTROL", testHandler(kv)) + client := mockBuilder(). + Route("POST /CMD_API_DNS_CONTROL", nil, + servermock.CheckQueryParameter().Strict(). + With("domain", "example.com"). + With("json", "yes"), + servermock.CheckForm().UsePostForm().Strict(). + With("action", "delete"). + With("name", "foo"). + With("type", "TXT"). + With("value", "txtTXTtxt"), + ). + Build(t) record := Record{ Name: "foo", @@ -133,16 +93,16 @@ func TestClient_DeleteRecord(t *testing.T) { Value: "txtTXTtxt", } - err := client.DeleteRecord(context.Background(), "example.com", record) + err := client.DeleteRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/CMD_API_DNS_CONTROL", func(rw http.ResponseWriter, req *http.Request) { - http.Error(rw, newJSONErrorf("OOPS"), http.StatusInternalServerError) - }) + client := mockBuilder(). + Route("POST /CMD_API_DNS_CONTROL", + servermock.JSONEncode(newAPIError("OOPS")). + WithStatusCode(http.StatusInternalServerError)). + Build(t) record := Record{ Name: "foo", @@ -150,6 +110,6 @@ func TestClient_DeleteRecord_error(t *testing.T) { Value: "txtTXTtxt", } - err := client.DeleteRecord(context.Background(), "example.com", record) + err := client.DeleteRecord(t.Context(), "example.com", record) require.EqualError(t, err, "[status code 500] Cannot View Dns Record: OOPS") } diff --git a/providers/dns/dns_providers_test.go b/providers/dns/dns_providers_test.go index 1f39e2bdd..3b82784b4 100644 --- a/providers/dns/dns_providers_test.go +++ b/providers/dns/dns_providers_test.go @@ -13,6 +13,7 @@ var envTest = tester.NewEnvTest("EXEC_PATH") func TestKnownDNSProviderSuccess(t *testing.T) { defer envTest.RestoreEnv() + envTest.Apply(map[string]string{ "EXEC_PATH": "abc", }) @@ -26,6 +27,7 @@ func TestKnownDNSProviderSuccess(t *testing.T) { func TestKnownDNSProviderError(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() provider, err := NewDNSChallengeProviderByName("exec") diff --git a/providers/dns/dnsexit/dnsexit.go b/providers/dns/dnsexit/dnsexit.go new file mode 100644 index 000000000..ce9373a50 --- /dev/null +++ b/providers/dns/dnsexit/dnsexit.go @@ -0,0 +1,163 @@ +// Package dnsexit implements a DNS provider for solving the DNS-01 challenge using DNSExit. +package dnsexit + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/dnsexit/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "DNSEXIT_" + + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIKey string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for DNSExit. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("dnsexit: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for DNSExit. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("dnsexit: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.APIKey) + if err != nil { + return nil, fmt.Errorf("dnsexit: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("dnsexit: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("dnsexit: %w", err) + } + + record := internal.Record{ + Type: "TXT", + Name: subDomain, + Content: info.Value, + TTL: toMinutes(d.config.TTL), + } + + err = d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record) + if err != nil { + return fmt.Errorf("dnsexit: add record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("dnsexit: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("dnsexit: %w", err) + } + + record := internal.Record{ + Type: "TXT", + Name: subDomain, + Content: info.Value, + } + + err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), record) + if err != nil { + return fmt.Errorf("dnsexit: add record: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func toMinutes(seconds int) int { + i := seconds / 60 + if seconds%60 > 0 { + i++ + } + + return i +} diff --git a/providers/dns/dnsexit/dnsexit.toml b/providers/dns/dnsexit/dnsexit.toml new file mode 100644 index 000000000..0d5321835 --- /dev/null +++ b/providers/dns/dnsexit/dnsexit.toml @@ -0,0 +1,22 @@ +Name = "DNSExit" +Description = '''''' +URL = "https://dnsexit.com" +Code = "dnsexit" +Since = "v4.32.0" + +Example = ''' +DNSEXIT_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns dnsexit -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + DNSEXIT_API_KEY = "API key" + [Configuration.Additional] + DNSEXIT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + DNSEXIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" + DNSEXIT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + DNSEXIT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://dnsexit.com/dns/dns-api/" diff --git a/providers/dns/dnsexit/dnsexit_test.go b/providers/dns/dnsexit/dnsexit_test.go new file mode 100644 index 000000000..31fe61497 --- /dev/null +++ b/providers/dns/dnsexit/dnsexit_test.go @@ -0,0 +1,165 @@ +package dnsexit + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "key", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "dnsexit: some credentials information are missing: DNSEXIT_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + expected string + }{ + { + desc: "success", + apiKey: "key", + }, + { + desc: "missing credentials", + expected: "dnsexit: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIKey = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With("apikey", "secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("POST /", + servermock.ResponseFromInternal("success.json"), + servermock.CheckRequestJSONBodyFromInternal("add_record-request.json"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("POST /", + servermock.ResponseFromInternal("success.json"), + servermock.CheckRequestJSONBodyFromInternal("delete_record-request.json"), + ). + Build(t) + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/dnsexit/internal/client.go b/providers/dns/dnsexit/internal/client.go new file mode 100644 index 000000000..9b0164846 --- /dev/null +++ b/providers/dns/dnsexit/internal/client.go @@ -0,0 +1,156 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://api.dnsexit.com/dns/" + +// Client the DNSExit API client. +type Client struct { + apiKey string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(apiKey string) (*Client, error) { + if apiKey == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + apiKey: apiKey, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// AddRecord adds a record. +// https://dnsexit.com/dns/dns-api/#example-add-spf +// https://dnsexit.com/dns/dns-api/#example-lse +func (c *Client) AddRecord(ctx context.Context, domain string, record Record) error { + payload := APIRequest{ + Domain: domain, + Add: []Record{record}, + } + + req, err := newJSONRequest(ctx, http.MethodPost, c.BaseURL, payload) + if err != nil { + return err + } + + err = c.do(req) + if err != nil { + return err + } + + return nil +} + +// DeleteRecord deletes a record. +// https://dnsexit.com/dns/dns-api/#delete-a-record +func (c *Client) DeleteRecord(ctx context.Context, domain string, record Record) error { + payload := APIRequest{ + Domain: domain, + Delete: []Record{record}, + } + + req, err := newJSONRequest(ctx, http.MethodPost, c.BaseURL, payload) + if err != nil { + return err + } + + err = c.do(req) + if err != nil { + return err + } + + return nil +} + +func (c *Client) do(req *http.Request) error { + useragent.SetHeader(req.Header) + + req.Header.Set("apikey", c.apiKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode > http.StatusBadRequest { + return parseError(req, resp) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + result := &APIResponse{} + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + if result.Code != 0 { + return result + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIResponse + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/dnsexit/internal/client_test.go b/providers/dns/dnsexit/internal/client_test.go new file mode 100644 index 000000000..26ea01203 --- /dev/null +++ b/providers/dns/dnsexit/internal/client_test.go @@ -0,0 +1,111 @@ +package internal + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + client.BaseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With("apikey", "secret"), + ) +} + +func TestClient_AddRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture("success.json"), + servermock.CheckRequestJSONBodyFromFixture("add_record-request.json"), + ). + Build(t) + + record := Record{ + Type: "TXT", + Name: "_acme-challenge", + Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 2, + } + + err := client.AddRecord(context.Background(), "example.com", record) + require.NoError(t, err) +} + +func TestClient_AddRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest), + ). + Build(t) + + record := Record{ + Type: "TXT", + Name: "_acme-challenge", + Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 480, + Overwrite: true, + } + + err := client.AddRecord(context.Background(), "example.com", record) + require.Error(t, err) + + require.EqualError(t, err, "JSON Defined Record Type not Supported (code=6)") +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture("success.json"), + servermock.CheckRequestJSONBodyFromFixture("delete_record-request.json"), + ). + Build(t) + + record := Record{ + Type: "TXT", + Name: "_acme-challenge", + Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + } + + err := client.DeleteRecord(context.Background(), "example.com", record) + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest), + ). + Build(t) + + record := Record{ + Type: "TXT", + Name: "foo", + Content: "txtTXTtxt", + } + + err := client.DeleteRecord(context.Background(), "example.com", record) + + require.Error(t, err) + + require.EqualError(t, err, "JSON Defined Record Type not Supported (code=6)") +} diff --git a/providers/dns/dnsexit/internal/fixtures/add_record-request.json b/providers/dns/dnsexit/internal/fixtures/add_record-request.json new file mode 100644 index 000000000..6e5e2b520 --- /dev/null +++ b/providers/dns/dnsexit/internal/fixtures/add_record-request.json @@ -0,0 +1,11 @@ +{ + "domain": "example.com", + "add": [ + { + "type": "TXT", + "name": "_acme-challenge", + "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 2 + } + ] +} diff --git a/providers/dns/dnsexit/internal/fixtures/delete_record-request.json b/providers/dns/dnsexit/internal/fixtures/delete_record-request.json new file mode 100644 index 000000000..dcfef9cdf --- /dev/null +++ b/providers/dns/dnsexit/internal/fixtures/delete_record-request.json @@ -0,0 +1,10 @@ +{ + "domain": "example.com", + "delete": [ + { + "type": "TXT", + "name": "_acme-challenge", + "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + } + ] +} diff --git a/providers/dns/dnsexit/internal/fixtures/error.json b/providers/dns/dnsexit/internal/fixtures/error.json new file mode 100644 index 000000000..9ba835895 --- /dev/null +++ b/providers/dns/dnsexit/internal/fixtures/error.json @@ -0,0 +1,4 @@ +{ + "code": 6, + "message": "JSON Defined Record Type not Supported" +} diff --git a/providers/dns/dnsexit/internal/fixtures/success.json b/providers/dns/dnsexit/internal/fixtures/success.json new file mode 100644 index 000000000..3af47a936 --- /dev/null +++ b/providers/dns/dnsexit/internal/fixtures/success.json @@ -0,0 +1,7 @@ +{ + "code": 0, + "details": [ + "UPDATE Record A example.com. TTL(hh:mm) 08:00 IP 1.1.1.10" + ], + "message": "Success" +} diff --git a/providers/dns/dnsexit/internal/types.go b/providers/dns/dnsexit/internal/types.go new file mode 100644 index 000000000..db254549f --- /dev/null +++ b/providers/dns/dnsexit/internal/types.go @@ -0,0 +1,41 @@ +package internal + +import ( + "fmt" + "strings" +) + +type Record struct { + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Content string `json:"content,omitempty"` + TTL int `json:"ttl,omitempty"` // NOTE: ttl value is in minutes. + Overwrite bool `json:"overwrite,omitempty"` +} + +type APIRequest struct { + Domain string `json:"domain,omitempty"` + Add []Record `json:"add,omitempty"` + Delete []Record `json:"delete,omitempty"` + Update []Record `json:"update,omitempty"` +} + +// https://dnsexit.com/dns/dns-api/#server-reply + +type APIResponse struct { + Code int `json:"code,omitempty"` + Details []string `json:"details,omitempty"` + Message string `json:"message,omitempty"` +} + +func (a APIResponse) Error() string { + msg := new(strings.Builder) + + _, _ = fmt.Fprintf(msg, "%s (code=%d)", a.Message, a.Code) + + for _, detail := range a.Details { + _, _ = fmt.Fprintf(msg, ", %s", detail) + } + + return msg.String() +} diff --git a/providers/dns/dnshomede/dnshomede.go b/providers/dns/dnshomede/dnshomede.go index 1b81be744..c76ed6de2 100644 --- a/providers/dns/dnshomede/dnshomede.go +++ b/providers/dns/dnshomede/dnshomede.go @@ -6,12 +6,12 @@ import ( "errors" "fmt" "net/http" - "strings" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/dnshomede/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -57,14 +57,15 @@ type DNSProvider struct { // Credentials must be passed in the environment variable: DNSHOMEDE_CREDENTIALS. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() + values, err := env.Get(EnvCredentials) if err != nil { return nil, fmt.Errorf("dnshomede: %w", err) } - credentials, err := parseCredentials(values[EnvCredentials]) + credentials, err := env.ParsePairs(values[EnvCredentials]) if err != nil { - return nil, fmt.Errorf("dnshomede: %w", err) + return nil, fmt.Errorf("dnshomede: credentials: %w", err) } config.Credentials = credentials @@ -93,6 +94,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client := internal.NewClient(config.Credentials) + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } @@ -131,19 +138,3 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } - -func parseCredentials(raw string) (map[string]string, error) { - credentials := make(map[string]string) - - credStrings := strings.Split(strings.TrimSuffix(raw, ","), ",") - for _, credPair := range credStrings { - data := strings.Split(credPair, ":") - if len(data) != 2 { - return nil, fmt.Errorf("invalid credential pair: %q", credPair) - } - - credentials[strings.TrimSpace(data[0])] = strings.TrimSpace(data[1]) - } - - return credentials, nil -} diff --git a/providers/dns/dnshomede/dnshomede.toml b/providers/dns/dnshomede/dnshomede.toml index 3aafb4ef8..9c3b65277 100644 --- a/providers/dns/dnshomede/dnshomede.toml +++ b/providers/dns/dnshomede/dnshomede.toml @@ -6,17 +6,17 @@ Since = "v4.10.0" Example = ''' DNSHOMEDE_CREDENTIALS=example.org:password \ -lego --email you@example.com --dns dnshomede -d '*.example.com' -d example.com run +lego --dns dnshomede -d '*.example.com' -d example.com run DNSHOMEDE_CREDENTIALS=my.example.org:password1,demo.example.org:password2 \ -lego --email you@example.com --dns dnshomede -d my.example.org -d demo.example.org +lego --dns dnshomede -d my.example.org -d demo.example.org ''' [Configuration] [Configuration.Credentials] DNSHOMEDE_CREDENTIALS = "Comma-separated list of domain:password credential pairs" [Configuration.Additional] - DNSHOMEDE_POLLING_INTERVAL = "Time between DNS propagation checks" - DNSHOMEDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation; defaults to 300s (5 minutes)" - DNSHOMEDE_SEQUENCE_INTERVAL = "Time between sequential requests" - DNSHOMEDE_HTTP_TIMEOUT = "API request timeout" + DNSHOMEDE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 1200)" + DNSHOMEDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 2)" + DNSHOMEDE_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 120)" + DNSHOMEDE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" diff --git a/providers/dns/dnshomede/dnshomede_test.go b/providers/dns/dnshomede/dnshomede_test.go index 6b79912e8..5035a7837 100644 --- a/providers/dns/dnshomede/dnshomede_test.go +++ b/providers/dns/dnshomede/dnshomede_test.go @@ -34,7 +34,7 @@ func TestNewDNSProvider(t *testing.T) { envVars: map[string]string{ EnvCredentials: ",", }, - expected: `dnshomede: invalid credential pair: ""`, + expected: `dnshomede: credentials: incorrect pair: `, }, { desc: "missing password", @@ -55,7 +55,7 @@ func TestNewDNSProvider(t *testing.T) { envVars: map[string]string{ EnvCredentials: "example.org:123,example.net", }, - expected: `dnshomede: invalid credential pair: "example.net"`, + expected: "dnshomede: credentials: incorrect pair: example.net", }, { desc: "missing credentials", @@ -69,6 +69,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -144,6 +145,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -157,6 +159,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/dnshomede/internal/client_test.go b/providers/dns/dnshomede/internal/client_test.go index e6f2c1b7d..6e1593fe7 100644 --- a/providers/dns/dnshomede/internal/client_test.go +++ b/providers/dns/dnshomede/internal/client_test.go @@ -1,89 +1,109 @@ package internal import ( - "context" "fmt" - "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, credentials map[string]string, handler http.HandlerFunc) *Client { - t.Helper() +func setupClient(credentials map[string]string) func(server *httptest.Server) (*Client, error) { + return func(server *httptest.Server) (*Client, error) { + client := NewClient(credentials) + client.HTTPClient = server.Client() + client.baseURL = server.URL - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", handler) - - client := NewClient(credentials) - client.HTTPClient = server.Client() - client.baseURL = server.URL - - return client + return client, nil + } } func TestClient_Add(t *testing.T) { txtValue := "123456789012" - client := setupTest(t, map[string]string{"example.org": "secret"}, handlerMock(addAction, txtValue)) + client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.org": "secret"})). + Route("POST /", + servermock.RawStringResponse(fmt.Sprintf("%s %s", successCode, txtValue)), + servermock.CheckQueryParameter().Strict(). + With("acme", addAction).With("txt", txtValue)). + Build(t) - err := client.Add(context.Background(), "example.org", txtValue) + err := client.Add(t.Context(), "example.org", txtValue) require.NoError(t, err) } func TestClient_Add_error(t *testing.T) { txtValue := "123456789012" - client := setupTest(t, map[string]string{"example.com": "secret"}, handlerMock(addAction, txtValue)) + client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.com": "secret"})). + Route("POST /", + servermock.RawStringResponse(fmt.Sprintf("%s %s", successCode, txtValue)), + servermock.CheckQueryParameter().Strict(). + With("acme", addAction).With("txt", txtValue)). + Build(t) - err := client.Add(context.Background(), "example.org", txtValue) - require.Error(t, err) + err := client.Add(t.Context(), "example.org", txtValue) + + require.EqualError(t, err, "domain example.org not found in credentials, check your credentials map") } func TestClient_Remove(t *testing.T) { txtValue := "ABCDEFGHIJKL" - client := setupTest(t, map[string]string{"example.org": "secret"}, handlerMock(removeAction, txtValue)) + client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.org": "secret"})). + Route("POST /", + servermock.RawStringResponse(fmt.Sprintf("%s %s", successCode, txtValue)), + servermock.CheckQueryParameter().Strict(). + With("acme", removeAction).With("txt", txtValue)). + Build(t) - err := client.Remove(context.Background(), "example.org", txtValue) + err := client.Remove(t.Context(), "example.org", txtValue) require.NoError(t, err) } func TestClient_Remove_error(t *testing.T) { txtValue := "ABCDEFGHIJKL" - client := setupTest(t, map[string]string{"example.com": "secret"}, handlerMock(removeAction, txtValue)) + testCases := []struct { + desc string + hostname string + response string + expected string + }{ + { + desc: "response error - txt", + hostname: "example.com", + response: "error - no valid acme txt record", + expected: "error - no valid acme txt record", + }, + { + desc: "response error - acme", + hostname: "example.com", + response: "nochg 1234:1234:1234:1234:1234:1234:1234:1234", + expected: "nochg 1234:1234:1234:1234:1234:1234:1234:1234", + }, + { + desc: "credential error", + hostname: "example.org", + response: fmt.Sprintf("%s %s", successCode, txtValue), + expected: "domain example.org not found in credentials, check your credentials map", + }, + } - err := client.Remove(context.Background(), "example.org", txtValue) - require.Error(t, err) -} + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() -func handlerMock(action, value string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - rw.WriteHeader(http.StatusOK) + client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.com": "secret"})). + Route("POST /", + servermock.RawStringResponse(test.response), + servermock.CheckQueryParameter().Strict(). + With("acme", removeAction).With("txt", txtValue)). + Build(t) - query := req.URL.Query() - - if query.Get("acme") != action { - _, _ = rw.Write([]byte("nochg 1234:1234:1234:1234:1234:1234:1234:1234")) - return - } - - txtValue := query.Get("txt") - if len(txtValue) < 12 { - _, _ = rw.Write([]byte("error - no valid acme txt record")) - return - } - - if txtValue != value { - http.Error(rw, fmt.Sprintf("got: %q, expected: %q", txtValue, value), http.StatusBadRequest) - return - } - - _, _ = fmt.Fprintf(rw, "%s %s", successCode, txtValue) + err := client.Remove(t.Context(), test.hostname, txtValue) + require.EqualError(t, err, test.expected) + }) } } diff --git a/providers/dns/dnshomede/internal/readme.md b/providers/dns/dnshomede/internal/readme.md index 014b062a1..622c4354d 100644 --- a/providers/dns/dnshomede/internal/readme.md +++ b/providers/dns/dnshomede/internal/readme.md @@ -16,7 +16,7 @@ Always returns StatusOK (200) If the API call works the first word of the response body is `successfully`. -If an error encoured the response body is `error - `. +If an error occurs the response body is `error - `. Can be a POST or a GET. @@ -35,6 +35,6 @@ Always returns StatusOK (200) If the API call works the first word of the response body is `successfully`. -If an error encoured the response body is `error - `. +If an error occurs the response body is `error - `. Can be a POST or a GET. diff --git a/providers/dns/dnsimple/dnsimple.go b/providers/dns/dnsimple/dnsimple.go index 0c1d30678..adf7d48e2 100644 --- a/providers/dns/dnsimple/dnsimple.go +++ b/providers/dns/dnsimple/dnsimple.go @@ -8,9 +8,11 @@ import ( "strconv" "time" - "github.com/dnsimple/dnsimple-go/dnsimple" + "github.com/dnsimple/dnsimple-go/v4/dnsimple" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" "golang.org/x/oauth2" ) @@ -28,6 +30,8 @@ const ( EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Debug bool @@ -76,8 +80,14 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("dnsimple: OAuth token is missing") } - ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: config.AccessToken}) - client := dnsimple.NewClient(oauth2.NewClient(context.Background(), ts)) + client := dnsimple.NewClient( + clientdebug.Wrap( + oauth2.NewClient( + context.Background(), + oauth2.StaticTokenSource(&oauth2.Token{AccessToken: config.AccessToken}), + ), + ), + ) client.SetUserAgent(useragent.Get()) if config.BaseURL != "" { @@ -91,14 +101,16 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) - zoneName, err := d.getHostedZone(info.EffectiveFQDN) + zoneName, err := d.getHostedZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("dnsimple: %w", err) } - accountID, err := d.getAccountID() + accountID, err := d.getAccountID(ctx) if err != nil { return fmt.Errorf("dnsimple: %w", err) } @@ -108,7 +120,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("dnsimple: %w", err) } - _, err = d.client.Zones.CreateRecord(context.Background(), accountID, zoneName, recordAttributes) + _, err = d.client.Zones.CreateRecord(ctx, accountID, zoneName, recordAttributes) if err != nil { return fmt.Errorf("dnsimple: API call failed: %w", err) } @@ -118,21 +130,24 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) - records, err := d.findTxtRecords(info.EffectiveFQDN) + records, err := d.findTxtRecords(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("dnsimple: %w", err) } - accountID, err := d.getAccountID() + accountID, err := d.getAccountID(ctx) if err != nil { return fmt.Errorf("dnsimple: %w", err) } var lastErr error + for _, rec := range records { - _, err := d.client.Zones.DeleteRecord(context.Background(), accountID, rec.ZoneID, rec.ID) + _, err := d.client.Zones.DeleteRecord(ctx, accountID, rec.ZoneID, rec.ID) if err != nil { lastErr = fmt.Errorf("dnsimple: %w", err) } @@ -147,45 +162,36 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } -func (d *DNSProvider) getHostedZone(domain string) (string, error) { +func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (string, error) { authZone, err := dns01.FindZoneByFqdn(domain) if err != nil { return "", fmt.Errorf("could not find zone for FQDN %q: %w", domain, err) } - accountID, err := d.getAccountID() + accountID, err := d.getAccountID(ctx) if err != nil { return "", err } - zoneName := dns01.UnFqdn(authZone) - - zones, err := d.client.Zones.ListZones(context.Background(), accountID, &dnsimple.ZoneListOptions{NameLike: &zoneName}) + hostedZone, err := d.client.Zones.GetZone(ctx, accountID, dns01.UnFqdn(authZone)) if err != nil { - return "", fmt.Errorf("API call failed: %w", err) + return "", fmt.Errorf("get zone: %w", err) } - var hostedZone dnsimple.Zone - for _, zone := range zones.Data { - if zone.Name == zoneName { - hostedZone = zone - } - } - - if hostedZone.ID == 0 { + if hostedZone == nil || hostedZone.Data == nil || hostedZone.Data.ID == 0 { return "", fmt.Errorf("zone %s not found in DNSimple for domain %s", authZone, domain) } - return hostedZone.Name, nil + return hostedZone.Data.Name, nil } -func (d *DNSProvider) findTxtRecords(fqdn string) ([]dnsimple.ZoneRecord, error) { - zoneName, err := d.getHostedZone(fqdn) +func (d *DNSProvider) findTxtRecords(ctx context.Context, fqdn string) ([]dnsimple.ZoneRecord, error) { + zoneName, err := d.getHostedZone(ctx, fqdn) if err != nil { return nil, err } - accountID, err := d.getAccountID() + accountID, err := d.getAccountID(ctx) if err != nil { return nil, err } @@ -195,7 +201,7 @@ func (d *DNSProvider) findTxtRecords(fqdn string) ([]dnsimple.ZoneRecord, error) return nil, err } - result, err := d.client.Zones.ListRecords(context.Background(), accountID, zoneName, &dnsimple.ZoneRecordListOptions{Name: &subDomain, Type: dnsimple.String("TXT"), ListOptions: dnsimple.ListOptions{}}) + result, err := d.client.Zones.ListRecords(ctx, accountID, zoneName, &dnsimple.ZoneRecordListOptions{Name: &subDomain, Type: dnsimple.String("TXT"), ListOptions: dnsimple.ListOptions{}}) if err != nil { return nil, fmt.Errorf("API call has failed: %w", err) } @@ -217,8 +223,8 @@ func newTxtRecord(zoneName, fqdn, value string, ttl int) (dnsimple.ZoneRecordAtt }, nil } -func (d *DNSProvider) getAccountID() (string, error) { - whoamiResponse, err := d.client.Identity.Whoami(context.Background()) +func (d *DNSProvider) getAccountID(ctx context.Context) (string, error) { + whoamiResponse, err := d.client.Identity.Whoami(ctx) if err != nil { return "", err } diff --git a/providers/dns/dnsimple/dnsimple.toml b/providers/dns/dnsimple/dnsimple.toml index 4d31daae1..158fb7011 100644 --- a/providers/dns/dnsimple/dnsimple.toml +++ b/providers/dns/dnsimple/dnsimple.toml @@ -6,7 +6,7 @@ Since = "v0.3.0" Example = ''' DNSIMPLE_OAUTH_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \ -lego --email you@example.com --dns dnsimple -d '*.example.com' -d example.com run +lego --dns dnsimple -d '*.example.com' -d example.com run ''' Additional = ''' @@ -32,9 +32,9 @@ Only Account API tokens are supported, if you try to use a User API token you wi DNSIMPLE_OAUTH_TOKEN = "OAuth token" [Configuration.Additional] DNSIMPLE_BASE_URL = "API endpoint URL" - DNSIMPLE_POLLING_INTERVAL = "Time between DNS propagation check" - DNSIMPLE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - DNSIMPLE_TTL = "The TTL of the TXT record used for the DNS challenge" + DNSIMPLE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + DNSIMPLE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + DNSIMPLE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" [Links] API = "https://developer.dnsimple.com/v2/" diff --git a/providers/dns/dnsimple/dnsimple_test.go b/providers/dns/dnsimple/dnsimple_test.go index c07f965b4..2a52dd2de 100644 --- a/providers/dns/dnsimple/dnsimple_test.go +++ b/providers/dns/dnsimple/dnsimple_test.go @@ -51,6 +51,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) diff --git a/providers/dns/dnsmadeeasy/dnsmadeeasy.go b/providers/dns/dnsmadeeasy/dnsmadeeasy.go index 7f4ca2af3..69f2096fb 100644 --- a/providers/dns/dnsmadeeasy/dnsmadeeasy.go +++ b/providers/dns/dnsmadeeasy/dnsmadeeasy.go @@ -11,9 +11,11 @@ import ( "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/dnsmadeeasy/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -30,6 +32,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string @@ -44,15 +48,22 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { + tr := &http.Transport{} + + defaultTransport, ok := http.DefaultTransport.(*http.Transport) + if ok { + tr = defaultTransport.Clone() + } + + tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ - Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), + Transport: tr, }, } } @@ -102,7 +113,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, fmt.Errorf("dnsmadeeasy: %w", err) } - client.HTTPClient = config.HTTPClient + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + client.BaseURL, err = url.Parse(baseURL) if err != nil { return nil, err @@ -139,6 +155,7 @@ func (d *DNSProvider) Present(domainName, token, keyAuth string) error { if err != nil { return fmt.Errorf("dnsmadeeasy: unable to create record for %s: %w", name, err) } + return nil } @@ -161,6 +178,7 @@ func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error { // find matching records name := strings.Replace(info.EffectiveFQDN, "."+authZone, "", 1) + records, err := d.client.GetRecords(ctx, domain, name, "TXT") if err != nil { return fmt.Errorf("dnsmadeeasy: unable to get records for domain %s: %w", domain.Name, err) @@ -168,6 +186,7 @@ func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error { // delete records var lastError error + for _, record := range *records { err = d.client.DeleteRecord(ctx, record) if err != nil { diff --git a/providers/dns/dnsmadeeasy/dnsmadeeasy.toml b/providers/dns/dnsmadeeasy/dnsmadeeasy.toml index 28b38e771..d71ab5303 100644 --- a/providers/dns/dnsmadeeasy/dnsmadeeasy.toml +++ b/providers/dns/dnsmadeeasy/dnsmadeeasy.toml @@ -7,7 +7,7 @@ Since = "v0.4.0" Example = ''' DNSMADEEASY_API_KEY=xxxxxx \ DNSMADEEASY_API_SECRET=yyyyy \ -lego --email you@example.com --dns dnsmadeeasy -d '*.example.com' -d example.com run +lego --dns dnsmadeeasy -d '*.example.com' -d example.com run ''' [Configuration] @@ -16,10 +16,10 @@ lego --email you@example.com --dns dnsmadeeasy -d '*.example.com' -d example.com DNSMADEEASY_API_SECRET = "The API Secret key" [Configuration.Additional] DNSMADEEASY_SANDBOX = "Activate the sandbox (boolean)" - DNSMADEEASY_POLLING_INTERVAL = "Time between DNS propagation check" - DNSMADEEASY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - DNSMADEEASY_TTL = "The TTL of the TXT record used for the DNS challenge" - DNSMADEEASY_HTTP_TIMEOUT = "API request timeout" + DNSMADEEASY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + DNSMADEEASY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + DNSMADEEASY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + DNSMADEEASY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://api-docs.dnsmadeeasy.com/" diff --git a/providers/dns/dnsmadeeasy/dnsmadeeasy_test.go b/providers/dns/dnsmadeeasy/dnsmadeeasy_test.go index 5c508e60d..f6fc2e273 100644 --- a/providers/dns/dnsmadeeasy/dnsmadeeasy_test.go +++ b/providers/dns/dnsmadeeasy/dnsmadeeasy_test.go @@ -59,6 +59,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -135,6 +136,7 @@ func TestLivePresentAndCleanup(t *testing.T) { os.Setenv(EnvSandbox, "true") envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/dnsmadeeasy/internal/client.go b/providers/dns/dnsmadeeasy/internal/client.go index 491d5fd98..7963ad614 100644 --- a/providers/dns/dnsmadeeasy/internal/client.go +++ b/providers/dns/dnsmadeeasy/internal/client.go @@ -15,6 +15,7 @@ import ( "strconv" "time" + "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) @@ -57,10 +58,8 @@ func NewClient(apiKey, apiSecret string) (*Client, error) { func (c *Client) GetDomain(ctx context.Context, authZone string) (*Domain, error) { endpoint := c.BaseURL.JoinPath("dns", "managed", "name") - domainName := authZone[0 : len(authZone)-1] - query := endpoint.Query() - query.Set("domainname", domainName) + query.Set("domainname", dns01.UnFqdn(authZone)) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -69,6 +68,7 @@ func (c *Client) GetDomain(ctx context.Context, authZone string) (*Domain, error } domain := &Domain{} + err = c.do(req, domain) if err != nil { return nil, err @@ -92,6 +92,7 @@ func (c *Client) GetRecords(ctx context.Context, domain *Domain, recordName, rec } records := &recordsResponse{} + err = c.do(req, records) if err != nil { return nil, err @@ -173,10 +174,12 @@ func (c *Client) sign(req *http.Request, timestamp string) error { func computeHMAC(message, secret string) (string, error) { key := []byte(secret) h := hmac.New(sha1.New, key) + _, err := h.Write([]byte(message)) if err != nil { return "", err } + return hex.EncodeToString(h.Sum(nil)), nil } diff --git a/providers/dns/dnsmadeeasy/internal/client_test.go b/providers/dns/dnsmadeeasy/internal/client_test.go index 721214693..cde212fc8 100644 --- a/providers/dns/dnsmadeeasy/internal/client_test.go +++ b/providers/dns/dnsmadeeasy/internal/client_test.go @@ -2,14 +2,132 @@ package internal import ( "net/http" + "net/http/httptest" + "net/url" "testing" "time" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func Test_sign(t *testing.T) { +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("key", "secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With("x-dnsme-apiKey", "key"). + WithRegexp("x-dnsme-requestDate", `\w+, \d+ \w+ \d+ \d+:\d+:\d+ UTC`). + WithRegexp("x-dnsme-hmac", `[a-z0-9]+`), + ) +} + +func TestClient_GetDomain(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/managed/name", + servermock.RawStringResponse(`{"id": 1, "name": "foo"}`), + servermock.CheckQueryParameter().Strict(). + With("domainname", "example.com")). + Build(t) + + domain, err := client.GetDomain(t.Context(), "example.com.") + require.NoError(t, err) + + expected := &Domain{ + ID: 1, + Name: "foo", + } + + assert.Equal(t, expected, domain) +} + +func TestClient_GetRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/managed/1/records", + servermock.ResponseFromFixture("get_records.json"), + servermock.CheckQueryParameter().Strict(). + With("recordName", "foo"). + With("type", "TXT"), + ). + Build(t) + + domain := &Domain{ID: 1, Name: "foo"} + + records, err := client.GetRecords(t.Context(), domain, "foo", "TXT") + require.NoError(t, err) + + expected := []Record{ + { + ID: 1, + Type: "TXT", + Name: "foo", + Value: "aaa", + TTL: 60, + SourceID: 123, + }, + { + ID: 2, + Type: "TXT", + Name: "bar", + Value: "bbb", + TTL: 120, + SourceID: 456, + }, + } + + assert.Equal(t, &expected, records) +} + +func TestClient_CreateRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/managed/1/records", nil, + servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). + Build(t) + + domain := &Domain{ID: 1, Name: "foo"} + + record := &Record{ + ID: 1, + Type: "TXT", + Name: "foo", + Value: "aaa", + TTL: 60, + SourceID: 123, + } + + err := client.CreateRecord(t.Context(), domain, record) + require.NoError(t, err) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /dns/managed/123/records/1", nil). + Build(t) + + record := Record{ + ID: 1, + Type: "TXT", + Name: "foo", + Value: "aaa", + TTL: 60, + SourceID: 123, + } + + err := client.DeleteRecord(t.Context(), record) + require.NoError(t, err) +} + +func TestClient_sign(t *testing.T) { apiKey := "key" client := Client{apiKey: apiKey, apiSecret: "secret"} diff --git a/providers/dns/dnsmadeeasy/internal/fixtures/create_record-request.json b/providers/dns/dnsmadeeasy/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..9a08b6544 --- /dev/null +++ b/providers/dns/dnsmadeeasy/internal/fixtures/create_record-request.json @@ -0,0 +1,8 @@ +{ + "id": 1, + "type": "TXT", + "name": "foo", + "value": "aaa", + "ttl": 60, + "sourceId": 123 +} diff --git a/providers/dns/dnsmadeeasy/internal/fixtures/get_records.json b/providers/dns/dnsmadeeasy/internal/fixtures/get_records.json new file mode 100644 index 000000000..5667e5e1d --- /dev/null +++ b/providers/dns/dnsmadeeasy/internal/fixtures/get_records.json @@ -0,0 +1,20 @@ +{ + "data": [ + { + "id": 1, + "type": "TXT", + "name": "foo", + "value": "aaa", + "ttl": 60, + "sourceId": 123 + }, + { + "id": 2, + "type": "TXT", + "name": "bar", + "value": "bbb", + "ttl": 120, + "sourceId": 456 + } + ] +} diff --git a/providers/dns/dnspod/dnspod.go b/providers/dns/dnspod/dnspod.go index 5f8e84880..52a873c7b 100644 --- a/providers/dns/dnspod/dnspod.go +++ b/providers/dns/dnspod/dnspod.go @@ -8,8 +8,10 @@ import ( "strconv" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/nrdcg/dnspod-go" ) @@ -25,6 +27,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { LoginToken string @@ -79,7 +83,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { params := dnspod.CommonParams{LoginToken: config.LoginToken, Format: "json"} client := dnspod.NewClient(params) - client.HTTPClient = config.HTTPClient + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{client: client, config: config}, nil } @@ -126,6 +135,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return err } } + return nil } @@ -147,6 +157,7 @@ func (d *DNSProvider) getHostedZone(domain string) (string, string, error) { } var hostedZone dnspod.Domain + for _, zone := range zones { if zone.Name == dns01.UnFqdn(authZone) { hostedZone = zone @@ -154,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 @@ -182,6 +193,7 @@ func (d *DNSProvider) findTxtRecords(fqdn, zoneID, zoneName string) ([]dnspod.Re } var records []dnspod.Record + result, _, err := d.client.Records.List(zoneID, subDomain) if err != nil { return records, fmt.Errorf("API call has failed: %w", err) diff --git a/providers/dns/dnspod/dnspod.toml b/providers/dns/dnspod/dnspod.toml index 7723f12ed..162685d76 100644 --- a/providers/dns/dnspod/dnspod.toml +++ b/providers/dns/dnspod/dnspod.toml @@ -8,17 +8,17 @@ Since = "v0.4.0" Example = ''' DNSPOD_API_KEY=xxxxxx \ -lego --email you@example.com --dns dnspod -d '*.example.com' -d example.com run +lego --dns dnspod -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DNSPOD_API_KEY = "The user token" [Configuration.Additional] - DNSPOD_POLLING_INTERVAL = "Time between DNS propagation check" - DNSPOD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - DNSPOD_TTL = "The TTL of the TXT record used for the DNS challenge" - DNSPOD_HTTP_TIMEOUT = "API request timeout" + DNSPOD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + DNSPOD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + DNSPOD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" + DNSPOD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docs.dnspod.com/api/" diff --git a/providers/dns/dnspod/dnspod_test.go b/providers/dns/dnspod/dnspod_test.go index 640ec34c6..5d339353a 100644 --- a/providers/dns/dnspod/dnspod_test.go +++ b/providers/dns/dnspod/dnspod_test.go @@ -37,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -96,6 +97,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -109,6 +111,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/dode/dode.go b/providers/dns/dode/dode.go index 04393fb05..59ad785e8 100644 --- a/providers/dns/dode/dode.go +++ b/providers/dns/dode/dode.go @@ -8,9 +8,11 @@ import ( "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/dode/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -25,6 +27,8 @@ const ( EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Token string @@ -82,6 +86,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } diff --git a/providers/dns/dode/dode.toml b/providers/dns/dode/dode.toml index a6a6e8f29..eb629bb3e 100644 --- a/providers/dns/dode/dode.toml +++ b/providers/dns/dode/dode.toml @@ -6,18 +6,17 @@ Since = "v2.4.0" Example = ''' DODE_TOKEN=xxxxxx \ -lego --email you@example.com --dns dode -d '*.example.com' -d example.com run +lego --dns dode -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DODE_TOKEN = "API token" [Configuration.Additional] - DODE_POLLING_INTERVAL = "Time between DNS propagation check" - DODE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - DODE_TTL = "The TTL of the TXT record used for the DNS challenge" - DODE_HTTP_TIMEOUT = "API request timeout" - DODE_SEQUENCE_INTERVAL = "Time between sequential requests" + DODE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + DODE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + DODE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + DODE_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" [Links] API = "https://www.do.de/wiki/freie-ssl-tls-zertifikate-ueber-acme/" diff --git a/providers/dns/dode/dode_test.go b/providers/dns/dode/dode_test.go index 3d8e9395a..fefcc79b1 100644 --- a/providers/dns/dode/dode_test.go +++ b/providers/dns/dode/dode_test.go @@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -93,6 +94,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -106,6 +108,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/dode/internal/client.go b/providers/dns/dode/internal/client.go index 91fa439c7..6824e7c48 100644 --- a/providers/dns/dode/internal/client.go +++ b/providers/dns/dode/internal/client.go @@ -36,7 +36,7 @@ func NewClient(token string) *Client { // UpdateTxtRecord Update the domains TXT record // To update the TXT record we just need to make one simple get request. -func (c Client) UpdateTxtRecord(ctx context.Context, fqdn, txt string, clearRecord bool) error { +func (c *Client) UpdateTxtRecord(ctx context.Context, fqdn, txt string, clearRecord bool) error { endpoint := c.baseURL.JoinPath("letsencrypt") query := endpoint.Query() @@ -70,6 +70,7 @@ func (c Client) UpdateTxtRecord(ctx context.Context, fqdn, txt string, clearReco } var response apiResponse + err = json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) diff --git a/providers/dns/dode/internal/client_test.go b/providers/dns/dode/internal/client_test.go index 116ca8c4c..6fbaa8c1d 100644 --- a/providers/dns/dode/internal/client_test.go +++ b/providers/dns/dode/internal/client_test.go @@ -1,93 +1,44 @@ package internal import ( - "context" - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - query := req.URL.Query() - if query.Get("token") != "secret" { - http.Error(rw, fmt.Sprintf("invalid credentials: %q", query.Get("token")), http.StatusUnauthorized) - return - } - - if query.Get("domain") != "example.com" { - http.Error(rw, fmt.Sprintf("invalid domain: %q", query.Get("domain")), http.StatusBadRequest) - return - } - - if query.Has("action") { - if query.Get("action") != "delete" { - http.Error(rw, fmt.Sprintf("invalid action: %q", query.Get("action")), http.StatusBadRequest) - return - } - } else { - if query.Get("value") != "value" { - http.Error(rw, fmt.Sprintf("invalid value: %q", query.Get("value")), http.StatusBadRequest) - return - } - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) - return client + return client, nil } func TestClient_UpdateTxtRecord(t *testing.T) { - client := setupTest(t, http.MethodGet, "/letsencrypt", http.StatusOK, "success.json") + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /letsencrypt", servermock.ResponseFromFixture("success.json"), + servermock.CheckQueryParameter().Strict(). + With("domain", "example.com"). + With("token", "secret"). + With("value", "value")). + Build(t) - err := client.UpdateTxtRecord(context.Background(), "example.com.", "value", false) + err := client.UpdateTxtRecord(t.Context(), "example.com.", "value", false) require.NoError(t, err) } func TestClient_UpdateTxtRecord_clear(t *testing.T) { - client := setupTest(t, http.MethodGet, "/letsencrypt", http.StatusOK, "success.json") + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /letsencrypt", servermock.ResponseFromFixture("success.json"), + servermock.CheckQueryParameter().Strict(). + With("action", "delete"). + With("domain", "example.com"). + With("token", "secret")). + Build(t) - err := client.UpdateTxtRecord(context.Background(), "example.com.", "value", true) + err := client.UpdateTxtRecord(t.Context(), "example.com.", "value", true) require.NoError(t, err) } diff --git a/providers/dns/domeneshop/domeneshop.go b/providers/dns/domeneshop/domeneshop.go index d074ba53f..fb16b442e 100644 --- a/providers/dns/domeneshop/domeneshop.go +++ b/providers/dns/domeneshop/domeneshop.go @@ -8,9 +8,11 @@ import ( "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/domeneshop/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -25,6 +27,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIToken string @@ -83,6 +87,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } diff --git a/providers/dns/domeneshop/domeneshop.toml b/providers/dns/domeneshop/domeneshop.toml index 8dfe806e5..b74af598e 100644 --- a/providers/dns/domeneshop/domeneshop.toml +++ b/providers/dns/domeneshop/domeneshop.toml @@ -8,7 +8,7 @@ Since = "v4.3.0" Example = ''' DOMENESHOP_API_TOKEN= \ DOMENESHOP_API_SECRET= \ -lego --email example@example.com --dns domeneshop -d '*.example.com' -d example.com run +lego --dns domeneshop -d '*.example.com' -d example.com run ''' Additional = ''' @@ -24,9 +24,9 @@ Visit the following page for information on how to create API credentials with D DOMENESHOP_API_TOKEN = "API token" DOMENESHOP_API_SECRET = "API secret" [Configuration.Additional] - DOMENESHOP_POLLING_INTERVAL = "Time between DNS propagation check" - DOMENESHOP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - DOMENESHOP_HTTP_TIMEOUT = "API request timeout" + DOMENESHOP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)" + DOMENESHOP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" + DOMENESHOP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.domeneshop.no/docs" diff --git a/providers/dns/domeneshop/domeneshop_test.go b/providers/dns/domeneshop/domeneshop_test.go index 389975bca..086efd44a 100644 --- a/providers/dns/domeneshop/domeneshop_test.go +++ b/providers/dns/domeneshop/domeneshop_test.go @@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -130,6 +131,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -143,6 +145,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/domeneshop/internal/client.go b/providers/dns/domeneshop/internal/client.go index b7ebb9940..9ab964222 100644 --- a/providers/dns/domeneshop/internal/client.go +++ b/providers/dns/domeneshop/internal/client.go @@ -72,7 +72,7 @@ func (c *Client) GetDomainByName(ctx context.Context, domain string) (*Domain, e // CreateTXTRecord creates a TXT record with the provided host (subdomain) and data. // https://api.domeneshop.no/docs/#tag/dns/paths/~1domains~1{domainId}~1dns/post -func (c *Client) CreateTXTRecord(ctx context.Context, domain *Domain, host string, data string) error { +func (c *Client) CreateTXTRecord(ctx context.Context, domain *Domain, host, data string) error { endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domain.ID), "dns") record := DNSRecord{ @@ -92,7 +92,7 @@ func (c *Client) CreateTXTRecord(ctx context.Context, domain *Domain, host strin // DeleteTXTRecord deletes the DNS record matching the provided host and data. // https://api.domeneshop.no/docs/#tag/dns/paths/~1domains~1{domainId}~1dns~1{recordId}/delete -func (c *Client) DeleteTXTRecord(ctx context.Context, domain *Domain, host string, data string) error { +func (c *Client) DeleteTXTRecord(ctx context.Context, domain *Domain, host, data string) error { record, err := c.getDNSRecordByHostData(ctx, *domain, host, data) if err != nil { return err @@ -110,7 +110,7 @@ func (c *Client) DeleteTXTRecord(ctx context.Context, domain *Domain, host strin // getDNSRecordByHostData finds the first matching DNS record with the provided host and data. // https://api.domeneshop.no/docs/#operation/getDnsRecords -func (c *Client) getDNSRecordByHostData(ctx context.Context, domain Domain, host string, data string) (*DNSRecord, error) { +func (c *Client) getDNSRecordByHostData(ctx context.Context, domain Domain, host, data string) (*DNSRecord, error) { endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domain.ID), "dns") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) diff --git a/providers/dns/domeneshop/internal/client_test.go b/providers/dns/domeneshop/internal/client_test.go index 71205cac4..2f5fb0d95 100644 --- a/providers/dns/domeneshop/internal/client_test.go +++ b/providers/dns/domeneshop/internal/client_test.go @@ -1,124 +1,58 @@ package internal import ( - "context" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -const authorizationHeader = "Authorization" +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("token", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("token", "secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("token", "secret"), + ) } func TestClient_CreateTXTRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("POST /domains/1/dns", + servermock.ResponseFromFixture("create_record.json"), + servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). + Build(t) - mux.HandleFunc("/domains/1/dns", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "Basic dG9rZW46c2VjcmV0" { - http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized) - return - } - - _, _ = rw.Write([]byte(`{"id": 1}`)) - }) - - err := client.CreateTXTRecord(context.Background(), &Domain{ID: 1}, "example", "txtTXTtxt") + err := client.CreateTXTRecord(t.Context(), &Domain{ID: 1}, "example.com", "txtTXTtxt") require.NoError(t, err) } func TestClient_DeleteTXTRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /domains/1/dns", + servermock.ResponseFromFixture("delete_record.json")). + Route("DELETE /domains/1/dns/1", nil). + Build(t) - mux.HandleFunc("/domains/1/dns", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "Basic dG9rZW46c2VjcmV0" { - http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized) - return - } - - _, _ = rw.Write([]byte(`[ - { - "id": 1, - "host": "example.com", - "ttl": 3600, - "type": "TXT", - "data": "txtTXTtxt" - } -]`)) - }) - - mux.HandleFunc("/domains/1/dns/1", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "Basic dG9rZW46c2VjcmV0" { - http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized) - return - } - }) - - err := client.DeleteTXTRecord(context.Background(), &Domain{ID: 1}, "example.com", "txtTXTtxt") + err := client.DeleteTXTRecord(t.Context(), &Domain{ID: 1}, "example.com", "txtTXTtxt") require.NoError(t, err) } func TestClient_getDNSRecordByHostData(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /domains/1/dns", + servermock.ResponseFromFixture("getDnsRecords.json")). + Build(t) - mux.HandleFunc("/domains/1/dns", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "Basic dG9rZW46c2VjcmV0" { - http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized) - return - } - - _, _ = rw.Write([]byte(`[ - { - "id": 1, - "host": "example.com", - "ttl": 3600, - "type": "TXT", - "data": "txtTXTtxt" - } -]`)) - }) - - record, err := client.getDNSRecordByHostData(context.Background(), Domain{ID: 1}, "example.com", "txtTXTtxt") + record, err := client.getDNSRecordByHostData(t.Context(), Domain{ID: 1}, "example.com", "txtTXTtxt") require.NoError(t, err) expected := &DNSRecord{ @@ -133,45 +67,12 @@ func TestClient_getDNSRecordByHostData(t *testing.T) { } func TestClient_GetDomainByName(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /domains/", + servermock.ResponseFromFixture("getDomains.json")). + Build(t) - mux.HandleFunc("/domains", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "Basic dG9rZW46c2VjcmV0" { - http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized) - return - } - - _, _ = rw.Write([]byte(`[ - { - "id": 1, - "domain": "example.com", - "expiry_date": "2019-08-24", - "registered_date": "2019-08-24", - "renew": true, - "registrant": "Ola Nordmann", - "status": "active", - "nameservers": [ - "ns1.hyp.net", - "ns2.hyp.net", - "ns3.hyp.net" - ], - "services": { - "registrar": true, - "dns": true, - "email": true, - "webhotel": "none" - } - } -]`)) - }) - - domain, err := client.GetDomainByName(context.Background(), "example.com") + domain, err := client.GetDomainByName(t.Context(), "example.com") require.NoError(t, err) expected := &Domain{ diff --git a/providers/dns/domeneshop/internal/fixtures/create_record-request.json b/providers/dns/domeneshop/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..6bd3ca4ce --- /dev/null +++ b/providers/dns/domeneshop/internal/fixtures/create_record-request.json @@ -0,0 +1,7 @@ +{ + "data": "txtTXTtxt", + "host": "example.com", + "id": 0, + "ttl": 300, + "type": "TXT" +} diff --git a/providers/dns/domeneshop/internal/fixtures/create_record.json b/providers/dns/domeneshop/internal/fixtures/create_record.json new file mode 100644 index 000000000..2572ae5fe --- /dev/null +++ b/providers/dns/domeneshop/internal/fixtures/create_record.json @@ -0,0 +1,3 @@ +{ + "id": 1 +} diff --git a/providers/dns/domeneshop/internal/fixtures/delete_record.json b/providers/dns/domeneshop/internal/fixtures/delete_record.json new file mode 100644 index 000000000..f3f987eef --- /dev/null +++ b/providers/dns/domeneshop/internal/fixtures/delete_record.json @@ -0,0 +1,9 @@ +[ + { + "id": 1, + "host": "example.com", + "ttl": 3600, + "type": "TXT", + "data": "txtTXTtxt" + } +] diff --git a/providers/dns/domeneshop/internal/fixtures/getDnsRecords.json b/providers/dns/domeneshop/internal/fixtures/getDnsRecords.json new file mode 100644 index 000000000..f3f987eef --- /dev/null +++ b/providers/dns/domeneshop/internal/fixtures/getDnsRecords.json @@ -0,0 +1,9 @@ +[ + { + "id": 1, + "host": "example.com", + "ttl": 3600, + "type": "TXT", + "data": "txtTXTtxt" + } +] diff --git a/providers/dns/domeneshop/internal/fixtures/getDomains.json b/providers/dns/domeneshop/internal/fixtures/getDomains.json new file mode 100644 index 000000000..b491d7f53 --- /dev/null +++ b/providers/dns/domeneshop/internal/fixtures/getDomains.json @@ -0,0 +1,22 @@ +[ + { + "id": 1, + "domain": "example.com", + "expiry_date": "2019-08-24", + "registered_date": "2019-08-24", + "renew": true, + "registrant": "Ola Nordmann", + "status": "active", + "nameservers": [ + "ns1.hyp.net", + "ns2.hyp.net", + "ns3.hyp.net" + ], + "services": { + "registrar": true, + "dns": true, + "email": true, + "webhotel": "none" + } + } +] diff --git a/providers/dns/dreamhost/dreamhost.go b/providers/dns/dreamhost/dreamhost.go index 8f0c850df..8663e18ce 100644 --- a/providers/dns/dreamhost/dreamhost.go +++ b/providers/dns/dreamhost/dreamhost.go @@ -10,9 +10,11 @@ import ( "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/dreamhost/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -26,6 +28,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string @@ -83,6 +87,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + if config.BaseURL != "" { client.BaseURL = config.BaseURL } @@ -93,6 +99,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) + err := d.client.AddRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value) if err != nil { return fmt.Errorf("dreamhost: %w", err) diff --git a/providers/dns/dreamhost/dreamhost.toml b/providers/dns/dreamhost/dreamhost.toml index a359ad97f..c3a9db360 100644 --- a/providers/dns/dreamhost/dreamhost.toml +++ b/providers/dns/dreamhost/dreamhost.toml @@ -6,17 +6,16 @@ Since = "v1.1.0" Example = ''' DREAMHOST_API_KEY="YOURAPIKEY" \ -lego --email you@example.com --dns dreamhost -d '*.example.com' -d example.com run +lego --dns dreamhost -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DREAMHOST_API_KEY = "The API key" [Configuration.Additional] - DREAMHOST_POLLING_INTERVAL = "Time between DNS propagation check" - DREAMHOST_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - DREAMHOST_TTL = "The TTL of the TXT record used for the DNS challenge" - DREAMHOST_HTTP_TIMEOUT = "API request timeout" + DREAMHOST_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 60)" + DREAMHOST_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 3600)" + DREAMHOST_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://help.dreamhost.com/hc/en-us/articles/217560167-API_overview" diff --git a/providers/dns/dreamhost/dreamhost_test.go b/providers/dns/dreamhost/dreamhost_test.go index 0f91ffae2..5af0b892d 100644 --- a/providers/dns/dreamhost/dreamhost_test.go +++ b/providers/dns/dreamhost/dreamhost_test.go @@ -1,13 +1,12 @@ package dreamhost import ( - "fmt" - "net/http" "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -23,22 +22,15 @@ const ( fakeKeyAuth = "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI" ) -func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIKey = fakeAPIKey + config.BaseURL = server.URL + config.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - config := NewDefaultConfig() - config.APIKey = fakeAPIKey - config.BaseURL = server.URL - config.HTTPClient = server.Client() - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - return provider, mux + return NewDNSProviderConfig(config) + }) } func TestNewDNSProvider(t *testing.T) { @@ -65,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -115,70 +108,51 @@ func TestNewDNSProviderConfig(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - - q := r.URL.Query() - assert.Equal(t, fakeAPIKey, q.Get("key")) - assert.Equal(t, "dns-add_record", q.Get("cmd")) - assert.Equal(t, "json", q.Get("format")) - assert.Equal(t, "_acme-challenge.example.com", q.Get("record")) - assert.Equal(t, fakeKeyAuth, q.Get("value")) - assert.Equal(t, "Managed+By+lego", q.Get("comment")) - - _, err := fmt.Fprintf(w, `{"data":"record_added","result":"success"}`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + provider := mockBuilder(). + Route("GET /", + servermock.RawStringResponse(`{"data":"record_added","result":"success"}`), + servermock.CheckQueryParameter().Strict(). + With("cmd", "dns-add_record"). + With("comment", "Managed+By+lego"). + With("format", "json"). + With("record", "_acme-challenge.example.com"). + With("type", "TXT"). + With("key", fakeAPIKey). + With("value", fakeKeyAuth), + ). + Build(t) err := provider.Present("example.com", "", fakeChallengeToken) require.NoError(t, err) } func TestDNSProvider_PresentFailed(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - - _, err := fmt.Fprintf(w, `{"data":"record_already_exists_remove_first","result":"error"}`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + provider := mockBuilder(). + Route("GET /", + servermock.RawStringResponse(`{"data":"record_already_exists_remove_first","result":"error"}`)). + Build(t) err := provider.Present("example.com", "", fakeChallengeToken) require.EqualError(t, err, "dreamhost: add TXT record failed: record_already_exists_remove_first") } func TestDNSProvider_Cleanup(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - - q := r.URL.Query() - assert.Equal(t, fakeAPIKey, q.Get("key"), "key mismatch") - assert.Equal(t, "dns-remove_record", q.Get("cmd"), "cmd mismatch") - assert.Equal(t, "json", q.Get("format")) - assert.Equal(t, "_acme-challenge.example.com", q.Get("record")) - assert.Equal(t, fakeKeyAuth, q.Get("value"), "value mismatch") - assert.Equal(t, "Managed+By+lego", q.Get("comment")) - - _, err := fmt.Fprintf(w, `{"data":"record_removed","result":"success"}`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + provider := mockBuilder(). + Route("GET /", + servermock.RawStringResponse(`{"data":"record_removed","result":"success"}`), + servermock.CheckQueryParameter().Strict(). + With("cmd", "dns-remove_record"). + With("comment", "Managed+By+lego"). + With("format", "json"). + With("record", "_acme-challenge.example.com"). + With("type", "TXT"). + With("key", fakeAPIKey). + With("value", fakeKeyAuth), + ). + Build(t) err := provider.CleanUp("example.com", "", fakeChallengeToken) - require.NoError(t, err, "failed to remove TXT record") + require.NoError(t, err) } func TestLivePresentAndCleanUp(t *testing.T) { @@ -187,6 +161,7 @@ func TestLivePresentAndCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/dreamhost/internal/client.go b/providers/dns/dreamhost/internal/client.go index dee808ac8..02b33ad57 100644 --- a/providers/dns/dreamhost/internal/client.go +++ b/providers/dns/dreamhost/internal/client.go @@ -101,6 +101,7 @@ func (c *Client) updateTxtRecord(ctx context.Context, endpoint *url.URL) error { } var response apiResponse + err = json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) diff --git a/providers/dns/dreamhost/internal/client_test.go b/providers/dns/dreamhost/internal/client_test.go index eff520df0..a836658f9 100644 --- a/providers/dns/dreamhost/internal/client_test.go +++ b/providers/dns/dreamhost/internal/client_test.go @@ -1,15 +1,59 @@ package internal import ( + "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -const fakeAPIKey = "asdf1234" +func setupClient(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.BaseURL = server.URL + client.HTTPClient = server.Client() + + return client, nil +} + +func TestClient_AddRecord(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", servermock.RawStringResponse(`{}`), + servermock.CheckQueryParameter().Strict(). + With("cmd", "dns-add_record"). + With("comment", "Managed+By+lego"). + With("format", "json"). + With("key", "secret"). + With("record", "example.com"). + With("type", "TXT"). + With("value", "aaa")). + Build(t) + + err := client.AddRecord(t.Context(), "example.com", "aaa") + require.NoError(t, err) +} + +func TestClient_RemoveRecord(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", servermock.RawStringResponse(`{}`), + servermock.CheckQueryParameter().Strict(). + With("cmd", "dns-remove_record"). + With("comment", "Managed+By+lego"). + With("format", "json"). + With("key", "secret"). + With("record", "example.com"). + With("type", "TXT"). + With("value", "aaa")). + Build(t) + + err := client.RemoveRecord(t.Context(), "example.com", "aaa") + require.NoError(t, err) +} func TestClient_buildQuery(t *testing.T) { + const fakeAPIKey = "asdf1234" + testCases := []struct { desc string apiKey string diff --git a/providers/dns/duckdns/duckdns.go b/providers/dns/duckdns/duckdns.go index 8cb82aed4..1aae0a06c 100644 --- a/providers/dns/duckdns/duckdns.go +++ b/providers/dns/duckdns/duckdns.go @@ -9,9 +9,11 @@ import ( "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/duckdns/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -26,6 +28,8 @@ const ( EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Token string @@ -83,6 +87,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } diff --git a/providers/dns/duckdns/duckdns.toml b/providers/dns/duckdns/duckdns.toml index a0ae92c2d..6866da57c 100644 --- a/providers/dns/duckdns/duckdns.toml +++ b/providers/dns/duckdns/duckdns.toml @@ -6,18 +6,17 @@ Since = "v0.5.0" Example = ''' DUCKDNS_TOKEN=xxxxxx \ -lego --email you@example.com --dns duckdns -d '*.example.com' -d example.com run +lego --dns duckdns -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DUCKDNS_TOKEN = "Account token" [Configuration.Additional] - DUCKDNS_POLLING_INTERVAL = "Time between DNS propagation check" - DUCKDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - DUCKDNS_TTL = "The TTL of the TXT record used for the DNS challenge" - DUCKDNS_HTTP_TIMEOUT = "API request timeout" - DUCKDNS_SEQUENCE_INTERVAL = "Time between sequential requests" + DUCKDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + DUCKDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + DUCKDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + DUCKDNS_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" [Links] API = "https://www.duckdns.org/spec.jsp" diff --git a/providers/dns/duckdns/duckdns_test.go b/providers/dns/duckdns/duckdns_test.go index b89966a36..769513fbf 100644 --- a/providers/dns/duckdns/duckdns_test.go +++ b/providers/dns/duckdns/duckdns_test.go @@ -37,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -94,6 +95,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -107,6 +109,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/duckdns/internal/client.go b/providers/dns/duckdns/internal/client.go index 0ed1bc864..c5d7ef97c 100644 --- a/providers/dns/duckdns/internal/client.go +++ b/providers/dns/duckdns/internal/client.go @@ -21,6 +21,7 @@ const defaultBaseURL = "https://www.duckdns.org/update" type Client struct { token string + baseURL string HTTPClient *http.Client } @@ -28,23 +29,24 @@ type Client struct { func NewClient(token string) *Client { return &Client{ token: token, + baseURL: defaultBaseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } -func (c Client) AddTXTRecord(ctx context.Context, domain, value string) error { +func (c *Client) AddTXTRecord(ctx context.Context, domain, value string) error { return c.UpdateTxtRecord(ctx, domain, value, false) } -func (c Client) RemoveTXTRecord(ctx context.Context, domain string) error { +func (c *Client) RemoveTXTRecord(ctx context.Context, domain string) error { return c.UpdateTxtRecord(ctx, domain, "", true) } // UpdateTxtRecord Update the domains TXT record // To update the TXT record we just need to make one simple get request. // In DuckDNS you only have one TXT record shared with the domain and all subdomains. -func (c Client) UpdateTxtRecord(ctx context.Context, domain, txt string, clearRecord bool) error { - endpoint, _ := url.Parse(defaultBaseURL) +func (c *Client) UpdateTxtRecord(ctx context.Context, domain, txt string, clearRecord bool) error { + endpoint, _ := url.Parse(c.baseURL) mainDomain := getMainDomain(domain) if mainDomain == "" { @@ -79,6 +81,7 @@ func (c Client) UpdateTxtRecord(ctx context.Context, domain, txt string, clearRe if body != "OK" { return fmt.Errorf("request to change TXT record for DuckDNS returned the following result (%s) this does not match expectation (OK) used url [%s]", body, endpoint) } + return nil } @@ -96,6 +99,7 @@ func getMainDomain(domain string) string { } firstSubDomainIndex := split[len(split)-3] + return domain[firstSubDomainIndex:] } diff --git a/providers/dns/duckdns/internal/client_test.go b/providers/dns/duckdns/internal/client_test.go index 4df17d049..aaa441fad 100644 --- a/providers/dns/duckdns/internal/client_test.go +++ b/providers/dns/duckdns/internal/client_test.go @@ -1,11 +1,50 @@ package internal import ( + "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func setupClient(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.baseURL = server.URL + client.HTTPClient = server.Client() + + return client, nil +} + +func TestClient_AddTXTRecord(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", servermock.RawStringResponse("OK"), + servermock.CheckQueryParameter().Strict(). + With("clear", "false"). + With("domains", "com"). + With("token", "secret"). + With("txt", "value")). + Build(t) + + err := client.AddTXTRecord(t.Context(), "example.com", "value") + require.NoError(t, err) +} + +func TestClient_RemoveTXTRecord(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", servermock.RawStringResponse("OK"), + servermock.CheckQueryParameter().Strict(). + With("clear", "true"). + With("domains", "com"). + With("token", "secret"). + With("txt", "")). + Build(t) + + err := client.RemoveTXTRecord(t.Context(), "example.com") + require.NoError(t, err) +} + func Test_getMainDomain(t *testing.T) { testCases := []struct { desc string diff --git a/providers/dns/dyn/dyn.go b/providers/dns/dyn/dyn.go index 3435110e5..0cd445c39 100644 --- a/providers/dns/dyn/dyn.go +++ b/providers/dns/dyn/dyn.go @@ -8,9 +8,11 @@ import ( "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/dyn/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -27,6 +29,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { CustomerName string @@ -89,6 +93,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } diff --git a/providers/dns/dyn/dyn.toml b/providers/dns/dyn/dyn.toml index e7607d0a2..c4b3563e0 100644 --- a/providers/dns/dyn/dyn.toml +++ b/providers/dns/dyn/dyn.toml @@ -8,7 +8,7 @@ Example = ''' DYN_CUSTOMER_NAME=xxxxxx \ DYN_USER_NAME=yyyyy \ DYN_PASSWORD=zzzz \ -lego --email you@example.com --dns dyn -d '*.example.com' -d example.com run +lego --dns dyn -d '*.example.com' -d example.com run ''' [Configuration] @@ -17,10 +17,10 @@ lego --email you@example.com --dns dyn -d '*.example.com' -d example.com run DYN_USER_NAME = "User name" DYN_PASSWORD = "Password" [Configuration.Additional] - DYN_POLLING_INTERVAL = "Time between DNS propagation check" - DYN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - DYN_TTL = "The TTL of the TXT record used for the DNS challenge" - DYN_HTTP_TIMEOUT = "API request timeout" + DYN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + DYN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + DYN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + DYN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://help.dyn.com/rest/" diff --git a/providers/dns/dyn/dyn_test.go b/providers/dns/dyn/dyn_test.go index 25f1f5614..5b4d1c6b6 100644 --- a/providers/dns/dyn/dyn_test.go +++ b/providers/dns/dyn/dyn_test.go @@ -71,6 +71,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -155,6 +156,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -168,6 +170,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/dyn/internal/client.go b/providers/dns/dyn/internal/client.go index 43981cc44..a54113eec 100644 --- a/providers/dns/dyn/internal/client.go +++ b/providers/dns/dyn/internal/client.go @@ -28,7 +28,7 @@ type Client struct { } // NewClient Creates a new Client. -func NewClient(customerName string, username string, password string) *Client { +func NewClient(customerName, username, password string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ @@ -127,6 +127,7 @@ func (c *Client) do(req *http.Request) (*APIResponse, error) { } var response APIResponse + err = json.Unmarshal(raw, &response) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) diff --git a/providers/dns/dyn/internal/client_test.go b/providers/dns/dyn/internal/client_test.go index 87bee1cd3..f166e7d8d 100644 --- a/providers/dns/dyn/internal/client_test.go +++ b/providers/dns/dyn/internal/client_test.go @@ -1,122 +1,59 @@ package internal import ( - "context" - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, handlerFunc http.HandlerFunc) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, handlerFunc) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("bob", "user", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) - return client + return client, nil } -func authenticatedHandler(method string, status int, file string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("bob", "user", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - token := req.Header.Get(authTokenHeader) - if token != "tok" { - http.Error(rw, fmt.Sprintf("invalid credentials: %q", token), http.StatusUnauthorized) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} - -func unauthenticatedHandler(method string, status int, file string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - token := req.Header.Get(authTokenHeader) - if token != "" { - http.Error(rw, fmt.Sprintf("invalid credentials: %q", token), http.StatusUnauthorized) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders()) } func TestClient_Publish(t *testing.T) { - client := setupTest(t, "/Zone/example.com", unauthenticatedHandler(http.MethodPut, http.StatusOK, "publish.json")) + client := mockBuilder(). + Route("PUT /Zone/example.com", servermock.ResponseFromFixture("publish.json"), + servermock.CheckRequestJSONBody(`{"publish":true,"notes":"my message"}`)). + Build(t) - err := client.Publish(context.Background(), "example.com", "my message") + err := client.Publish(t.Context(), "example.com", "my message") require.NoError(t, err) } func TestClient_AddTXTRecord(t *testing.T) { - client := setupTest(t, "/TXTRecord/example.com/example.com.", unauthenticatedHandler(http.MethodPost, http.StatusCreated, "create-txt-record.json")) + client := mockBuilder(). + Route("POST /TXTRecord/example.com/example.com.", servermock.ResponseFromFixture("create-txt-record.json"), + servermock.CheckRequestJSONBody(`{"rdata":{"txtdata":"txt"},"ttl":"120"}`)). + Build(t) - err := client.AddTXTRecord(context.Background(), "example.com", "example.com.", "txt", 120) + err := client.AddTXTRecord(t.Context(), "example.com", "example.com.", "txt", 120) require.NoError(t, err) } func TestClient_RemoveTXTRecord(t *testing.T) { - client := setupTest(t, "/TXTRecord/example.com/example.com.", unauthenticatedHandler(http.MethodDelete, http.StatusOK, "")) + client := mockBuilder(). + Route("DELETE /TXTRecord/example.com/example.com.", nil). + Build(t) - err := client.RemoveTXTRecord(context.Background(), "example.com", "example.com.") + err := client.RemoveTXTRecord(t.Context(), "example.com", "example.com.") require.NoError(t, err) } diff --git a/providers/dns/dyn/internal/session.go b/providers/dns/dyn/internal/session.go index 647080fa8..088510152 100644 --- a/providers/dns/dyn/internal/session.go +++ b/providers/dns/dyn/internal/session.go @@ -33,6 +33,7 @@ func (c *Client) login(ctx context.Context) (session, error) { } var s session + err = json.Unmarshal(dynRes.Data, &s) if err != nil { return session{}, errutils.NewUnmarshalError(req, http.StatusOK, dynRes.Data, err) diff --git a/providers/dns/dyn/internal/session_test.go b/providers/dns/dyn/internal/session_test.go index 76d5bef4e..349b1b190 100644 --- a/providers/dns/dyn/internal/session_test.go +++ b/providers/dns/dyn/internal/session_test.go @@ -2,21 +2,26 @@ package internal import ( "context" - "net/http" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func mockContext() context.Context { - return context.WithValue(context.Background(), tokenKey, "tok") +func mockContext(t *testing.T) context.Context { + t.Helper() + + return context.WithValue(t.Context(), tokenKey, "tok") } func TestClient_login(t *testing.T) { - client := setupTest(t, "/Session", unauthenticatedHandler(http.MethodPost, http.StatusOK, "login.json")) + client := mockBuilder(). + Route("POST /Session", servermock.ResponseFromFixture("login.json"), + servermock.CheckRequestJSONBody(`{"customer_name":"bob","user_name":"user","password":"secret"}`)). + Build(t) - sess, err := client.login(context.Background()) + sess, err := client.login(t.Context()) require.NoError(t, err) expected := session{Token: "tok", Version: "456"} @@ -25,16 +30,24 @@ func TestClient_login(t *testing.T) { } func TestClient_Logout(t *testing.T) { - client := setupTest(t, "/Session", authenticatedHandler(http.MethodDelete, http.StatusOK, "")) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + With(authTokenHeader, "tok"), + ). + Route("DELETE /Session", nil). + Build(t) - err := client.Logout(mockContext()) + err := client.Logout(mockContext(t)) require.NoError(t, err) } func TestClient_CreateAuthenticatedContext(t *testing.T) { - client := setupTest(t, "/Session", unauthenticatedHandler(http.MethodPost, http.StatusOK, "login.json")) + client := mockBuilder(). + Route("POST /Session", servermock.ResponseFromFixture("login.json"), + servermock.CheckRequestJSONBody(`{"customer_name":"bob","user_name":"user","password":"secret"}`)). + Build(t) - ctx, err := client.CreateAuthenticatedContext(context.Background()) + ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) at := getToken(ctx) diff --git a/providers/dns/dyndnsfree/dyndnsfree.go b/providers/dns/dyndnsfree/dyndnsfree.go new file mode 100644 index 000000000..09be2bfbd --- /dev/null +++ b/providers/dns/dyndnsfree/dyndnsfree.go @@ -0,0 +1,120 @@ +// Package dyndnsfree implements a DNS provider for solving the DNS-01 challenge using DynDnsFree.de API. +package dyndnsfree + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/dyndnsfree/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "DYNDNSFREE_" + + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "PASSWORD" + + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Username string + Password string + + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for DynDNSFree. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvPassword) + if err != nil { + return nil, fmt.Errorf("dyndnsfree: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for DynDNSFree. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("dyndnsfree: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.Username, config.Password) + if err != nil { + return nil, fmt.Errorf("dyndnsfree: new client: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("dyndnsforfree: could not find zone for domain %q: %w", domain, err) + } + + err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(authZone), dns01.UnFqdn(info.EffectiveFQDN), info.Value) + if err != nil { + return fmt.Errorf("dyndnsfree: add record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + // Records are deleted automatically. + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/dyndnsfree/dyndnsfree.toml b/providers/dns/dyndnsfree/dyndnsfree.toml new file mode 100644 index 000000000..e64bb0080 --- /dev/null +++ b/providers/dns/dyndnsfree/dyndnsfree.toml @@ -0,0 +1,23 @@ +Name = "DynDnsFree.de" +Description = '''''' +URL = "https://www.dyndnsfree.de" +Code = "dyndnsfree" +Since = "v4.23.0" + +Example = ''' +DYNDNSFREE_USERNAME="xxx" \ +DYNDNSFREE_PASSWORD="yyy" \ +lego --dns dyndnsfree -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + DYNDNSFREE_USERNAME = "Username" + DYNDNSFREE_PASSWORD = "Password" + [Configuration.Additional] + DYNDNSFREE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + DYNDNSFREE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + DYNDNSFREE_HTTP_TIMEOUT = "Request timeout in seconds (Default: 30)" + +[Links] + API = "https://www.dyndnsfree.de/user/hilfe.php?hsm=2" diff --git a/providers/dns/dyndnsfree/dyndnsfree_test.go b/providers/dns/dyndnsfree/dyndnsfree_test.go new file mode 100644 index 000000000..0b03bd27f --- /dev/null +++ b/providers/dns/dyndnsfree/dyndnsfree_test.go @@ -0,0 +1,146 @@ +package dyndnsfree + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "secret", + }, + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvUsername: "", + EnvPassword: "secret", + }, + expected: "dyndnsfree: some credentials information are missing: DYNDNSFREE_USERNAME", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "", + }, + expected: "dyndnsfree: some credentials information are missing: DYNDNSFREE_PASSWORD", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "dyndnsfree: some credentials information are missing: DYNDNSFREE_USERNAME,DYNDNSFREE_PASSWORD", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + username string + password string + expected string + }{ + { + desc: "success", + username: "user", + password: "secret", + }, + { + desc: "missing username", + username: "", + password: "secret", + expected: "dyndnsfree: new client: credentials missing", + }, + { + desc: "missing password", + username: "user", + password: "", + expected: "dyndnsfree: new client: credentials missing", + }, + { + desc: "missing credentials", + expected: "dyndnsfree: new client: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Username = test.username + config.Password = test.password + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/dyndnsfree/internal/client.go b/providers/dns/dyndnsfree/internal/client.go new file mode 100644 index 000000000..02a1f1607 --- /dev/null +++ b/providers/dns/dyndnsfree/internal/client.go @@ -0,0 +1,78 @@ +package internal + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const defaultBaseURL = "https://dynup.de/acme.php" + +type Client struct { + username string + password string + + baseURL string + HTTPClient *http.Client +} + +func NewClient(username, password string) (*Client, error) { + if username == "" || password == "" { + return nil, errors.New("credentials missing") + } + + return &Client{ + username: username, + password: password, + baseURL: defaultBaseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) AddTXTRecord(ctx context.Context, zone, hostname, value string) error { + baseURL, err := url.Parse(c.baseURL) + if err != nil { + return err + } + + query := baseURL.Query() + query.Set("username", c.username) + query.Set("password", c.password) + query.Set("hostname", zone) + query.Set("add_hostname", hostname) + query.Set("txt", value) + baseURL.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL.String(), http.NoBody) + if err != nil { + return err + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return errutils.NewUnexpectedResponseStatusCodeError(req, resp) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + if !bytes.Equal(raw, []byte("success")) { + return errors.New(string(raw)) + } + + return nil +} diff --git a/providers/dns/dyndnsfree/internal/client_test.go b/providers/dns/dyndnsfree/internal/client_test.go new file mode 100644 index 000000000..d6f1d276b --- /dev/null +++ b/providers/dns/dyndnsfree/internal/client_test.go @@ -0,0 +1,45 @@ +package internal + +import ( + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +func setupClient(server *httptest.Server) (*Client, error) { + client, err := NewClient("user", "secret") + if err != nil { + return nil, err + } + + client.baseURL = server.URL + client.HTTPClient = server.Client() + + return client, nil +} + +func TestAddTXTRecord(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", servermock.RawStringResponse("success"), + servermock.CheckQueryParameter().Strict(). + With("add_hostname", "sub.example.com"). + With("hostname", "example.com"). + With("password", "secret"). + With("txt", "value"). + With("username", "user")). + Build(t) + + err := client.AddTXTRecord(t.Context(), "example.com", "sub.example.com", "value") + require.NoError(t, err) +} + +func TestAddTXTRecord_error(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", servermock.RawStringResponse("error: authentification failed")). + Build(t) + + err := client.AddTXTRecord(t.Context(), "example.com", "sub.example.com", "value") + require.EqualError(t, err, "error: authentification failed") +} diff --git a/providers/dns/dynu/dynu.go b/providers/dns/dynu/dynu.go index d0c396a2c..11df45281 100644 --- a/providers/dns/dynu/dynu.go +++ b/providers/dns/dynu/dynu.go @@ -8,9 +8,11 @@ import ( "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/dynu/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -25,6 +27,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -83,7 +87,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { } client := internal.NewClient() - client.HTTPClient = tr.Wrap(config.HTTPClient) + + client.HTTPClient = clientdebug.Wrap(tr.Wrap(config.HTTPClient)) return &DNSProvider{config: config, client: client}, nil } diff --git a/providers/dns/dynu/dynu.toml b/providers/dns/dynu/dynu.toml index 7d12b428e..ae2367087 100644 --- a/providers/dns/dynu/dynu.toml +++ b/providers/dns/dynu/dynu.toml @@ -6,17 +6,17 @@ Since = "v3.5.0" Example = ''' DYNU_API_KEY=1234567890abcdefghijklmnopqrstuvwxyz \ -lego --email you@example.com --dns dynu -d '*.example.com' -d example.com run +lego --dns dynu -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DYNU_API_KEY = "API key" [Configuration.Additional] - DYNU_POLLING_INTERVAL = "Time between DNS propagation check" - DYNU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - DYNU_TTL = "The TTL of the TXT record used for the DNS challenge" - DYNU_HTTP_TIMEOUT = "API request timeout" + DYNU_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + DYNU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 180)" + DYNU_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + DYNU_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.dynu.com/en-US/Support/API" diff --git a/providers/dns/dynu/dynu_test.go b/providers/dns/dynu/dynu_test.go index fe2c22dfb..ffc7c3a00 100644 --- a/providers/dns/dynu/dynu_test.go +++ b/providers/dns/dynu/dynu_test.go @@ -38,6 +38,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -96,6 +97,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -109,6 +111,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/dynu/internal/auth.go b/providers/dns/dynu/internal/auth.go index 7a21a10e8..0a91445d2 100644 --- a/providers/dns/dynu/internal/auth.go +++ b/providers/dns/dynu/internal/auth.go @@ -46,6 +46,7 @@ func (t *TokenTransport) transport() http.RoundTripper { if t.Transport != nil { return t.Transport } + return http.DefaultTransport } diff --git a/providers/dns/dynu/internal/client.go b/providers/dns/dynu/internal/client.go index 6821863b3..59e90d592 100644 --- a/providers/dns/dynu/internal/client.go +++ b/providers/dns/dynu/internal/client.go @@ -12,8 +12,9 @@ import ( "strconv" "time" - "github.com/cenkalti/backoff/v4" + "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/log" + "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) @@ -34,7 +35,7 @@ func NewClient() *Client { } // GetRecords Get DNS records based on a hostname and resource record type. -func (c Client) GetRecords(ctx context.Context, hostname, recordType string) ([]DNSRecord, error) { +func (c *Client) GetRecords(ctx context.Context, hostname, recordType string) ([]DNSRecord, error) { endpoint := c.baseURL.JoinPath("dns", "record", hostname) query := endpoint.Query() @@ -42,6 +43,7 @@ func (c Client) GetRecords(ctx context.Context, hostname, recordType string) ([] endpoint.RawQuery = query.Encode() apiResp := RecordsResponse{} + err := c.doRetry(ctx, http.MethodGet, endpoint.String(), nil, &apiResp) if err != nil { return nil, err @@ -55,7 +57,7 @@ func (c Client) GetRecords(ctx context.Context, hostname, recordType string) ([] } // AddNewRecord Add a new DNS record for DNS service. -func (c Client) AddNewRecord(ctx context.Context, domainID int64, record DNSRecord) error { +func (c *Client) AddNewRecord(ctx context.Context, domainID int64, record DNSRecord) error { endpoint := c.baseURL.JoinPath("dns", strconv.FormatInt(domainID, 10), "record") reqBody, err := json.Marshal(record) @@ -64,6 +66,7 @@ func (c Client) AddNewRecord(ctx context.Context, domainID int64, record DNSReco } apiResp := RecordResponse{} + err = c.doRetry(ctx, http.MethodPost, endpoint.String(), reqBody, &apiResp) if err != nil { return err @@ -77,10 +80,11 @@ func (c Client) AddNewRecord(ctx context.Context, domainID int64, record DNSReco } // DeleteRecord Remove a DNS record from DNS service. -func (c Client) DeleteRecord(ctx context.Context, domainID, recordID int64) error { +func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int64) error { endpoint := c.baseURL.JoinPath("dns", strconv.FormatInt(domainID, 10), "record", strconv.FormatInt(recordID, 10)) apiResp := APIException{} + err := c.doRetry(ctx, http.MethodDelete, endpoint.String(), nil, &apiResp) if err != nil { return err @@ -94,10 +98,11 @@ func (c Client) DeleteRecord(ctx context.Context, domainID, recordID int64) erro } // GetRootDomain Get the root domain name based on a hostname. -func (c Client) GetRootDomain(ctx context.Context, hostname string) (*DNSHostname, error) { +func (c *Client) GetRootDomain(ctx context.Context, hostname string) (*DNSHostname, error) { endpoint := c.baseURL.JoinPath("dns", "getroot", hostname) apiResp := DNSHostname{} + err := c.doRetry(ctx, http.MethodGet, endpoint.String(), nil, &apiResp) if err != nil { return nil, err @@ -111,7 +116,7 @@ func (c Client) GetRootDomain(ctx context.Context, hostname string) (*DNSHostnam } // doRetry the API is really unstable, so we need to retry on EOF. -func (c Client) doRetry(ctx context.Context, method, uri string, body []byte, result any) error { +func (c *Client) doRetry(ctx context.Context, method, uri string, body []byte, result any) error { operation := func() error { return c.do(ctx, method, uri, body, result) } @@ -123,15 +128,10 @@ func (c Client) doRetry(ctx context.Context, method, uri string, body []byte, re bo := backoff.NewExponentialBackOff() bo.InitialInterval = 1 * time.Second - err := backoff.RetryNotify(operation, bo, notify) - if err != nil { - return err - } - - return nil + return wait.Retry(ctx, operation, backoff.WithBackOff(bo), backoff.WithNotify(notify)) } -func (c Client) do(ctx context.Context, method, uri string, body []byte, result any) error { +func (c *Client) do(ctx context.Context, method, uri string, body []byte, result any) error { var reqBody io.Reader if len(body) > 0 { reqBody = bytes.NewReader(body) diff --git a/providers/dns/dynu/internal/client_test.go b/providers/dns/dynu/internal/client_test.go index 7f33bc2c0..f70a8e377 100644 --- a/providers/dns/dynu/internal/client_test.go +++ b/providers/dns/dynu/internal/client_test.go @@ -1,53 +1,27 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient() + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - open, err := os.Open(file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client := NewClient() - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestGetRootDomain(t *testing.T) { @@ -65,9 +39,9 @@ func TestGetRootDomain(t *testing.T) { }{ { desc: "success", - pattern: "/dns/getroot/test.lego.freeddns.org", + pattern: "GET /dns/getroot/test.lego.freeddns.org", status: http.StatusOK, - file: "./fixtures/get_root_domain.json", + file: "get_root_domain.json", expected: expected{ domain: &DNSHostname{ APIException: &APIException{ @@ -82,9 +56,9 @@ func TestGetRootDomain(t *testing.T) { }, { desc: "invalid", - pattern: "/dns/getroot/test.lego.freeddns.org", + pattern: "GET /dns/getroot/test.lego.freeddns.org", status: http.StatusNotImplemented, - file: "./fixtures/get_root_domain_invalid.json", + file: "get_root_domain_invalid.json", expected: expected{ error: "API error: 501: Argument Exception: Invalid.", }, @@ -95,9 +69,11 @@ func TestGetRootDomain(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client := setupTest(t, http.MethodGet, test.pattern, test.status, test.file) + client := mockBuilder(). + Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status)). + Build(t) - domain, err := client.GetRootDomain(context.Background(), "test.lego.freeddns.org") + domain, err := client.GetRootDomain(t.Context(), "test.lego.freeddns.org") if test.expected.error != "" { assert.EqualError(t, err, test.expected.error) @@ -127,9 +103,9 @@ func TestGetRecords(t *testing.T) { }{ { desc: "success", - pattern: "/dns/record/_acme-challenge.lego.freeddns.org", + pattern: "GET /dns/record/_acme-challenge.lego.freeddns.org", status: http.StatusOK, - file: "./fixtures/get_records.json", + file: "get_records.json", expected: expected{ records: []DNSRecord{ { @@ -161,18 +137,18 @@ func TestGetRecords(t *testing.T) { }, { desc: "empty", - pattern: "/dns/record/_acme-challenge.lego.freeddns.org", + pattern: "GET /dns/record/_acme-challenge.lego.freeddns.org", status: http.StatusOK, - file: "./fixtures/get_records_empty.json", + file: "get_records_empty.json", expected: expected{ records: []DNSRecord{}, }, }, { desc: "invalid", - pattern: "/dns/record/_acme-challenge.lego.freeddns.org", + pattern: "GET /dns/record/_acme-challenge.lego.freeddns.org", status: http.StatusNotImplemented, - file: "./fixtures/get_records_invalid.json", + file: "get_records_invalid.json", expected: expected{ error: "API error: 501: Argument Exception: Invalid.", }, @@ -183,9 +159,13 @@ func TestGetRecords(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client := setupTest(t, http.MethodGet, test.pattern, test.status, test.file) + client := mockBuilder(). + Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status), + servermock.CheckQueryParameter().Strict(). + With("recordType", "TXT")). + Build(t) - records, err := client.GetRecords(context.Background(), "_acme-challenge.lego.freeddns.org", "TXT") + records, err := client.GetRecords(t.Context(), "_acme-challenge.lego.freeddns.org", "TXT") if test.expected.error != "" { assert.EqualError(t, err, test.expected.error) @@ -214,15 +194,15 @@ func TestAddNewRecord(t *testing.T) { }{ { desc: "success", - pattern: "/dns/9007481/record", + pattern: "POST /dns/9007481/record", status: http.StatusOK, - file: "./fixtures/add_new_record.json", + file: "add_new_record.json", }, { desc: "invalid", - pattern: "/dns/9007481/record", + pattern: "POST /dns/9007481/record", status: http.StatusNotImplemented, - file: "./fixtures/add_new_record_invalid.json", + file: "add_new_record_invalid.json", expected: expected{ error: "API error: 501: Argument Exception: Invalid.", }, @@ -233,7 +213,10 @@ func TestAddNewRecord(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client := setupTest(t, http.MethodPost, test.pattern, test.status, test.file) + client := mockBuilder(). + Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status), + servermock.CheckRequestJSONBodyFromFixture("add_new_record-request.json")). + Build(t) record := DNSRecord{ Type: "TXT", @@ -245,7 +228,7 @@ func TestAddNewRecord(t *testing.T) { TTL: 300, } - err := client.AddNewRecord(context.Background(), 9007481, record) + err := client.AddNewRecord(t.Context(), 9007481, record) if test.expected.error != "" { assert.EqualError(t, err, test.expected.error) @@ -271,15 +254,15 @@ func TestDeleteRecord(t *testing.T) { }{ { desc: "success", - pattern: "/", + pattern: "DELETE /", status: http.StatusOK, - file: "./fixtures/delete_record.json", + file: "delete_record.json", }, { desc: "invalid", - pattern: "/", + pattern: "DELETE /", status: http.StatusNotImplemented, - file: "./fixtures/delete_record_invalid.json", + file: "delete_record_invalid.json", expected: expected{ error: "API error: 501: Argument Exception: Invalid.", }, @@ -290,9 +273,11 @@ func TestDeleteRecord(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client := setupTest(t, http.MethodDelete, test.pattern, test.status, test.file) + client := mockBuilder(). + Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status)). + Build(t) - err := client.DeleteRecord(context.Background(), 9007481, 6041418) + err := client.DeleteRecord(t.Context(), 9007481, 6041418) if test.expected.error != "" { assert.EqualError(t, err, test.expected.error) diff --git a/providers/dns/dynu/internal/fixtures/add_new_record-request.json b/providers/dns/dynu/internal/fixtures/add_new_record-request.json new file mode 100644 index 000000000..f3c75ca36 --- /dev/null +++ b/providers/dns/dynu/internal/fixtures/add_new_record-request.json @@ -0,0 +1,9 @@ +{ + "recordType": "TXT", + "domainName": "lego.freeddns.org", + "nodeName": "_acme-challenge", + "hostname": "_acme-challenge.lego.freeddns.org", + "state": true, + "textData": "txt_txt_txt_txt_txt_txt_txt_2", + "ttl": 300 +} diff --git a/providers/dns/easydns/easydns.go b/providers/dns/easydns/easydns.go index 20ad27543..205063e7b 100644 --- a/providers/dns/easydns/easydns.go +++ b/providers/dns/easydns/easydns.go @@ -12,9 +12,11 @@ import ( "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/easydns/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -32,6 +34,8 @@ const ( EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Endpoint *url.URL @@ -74,6 +78,7 @@ func NewDNSProvider() (*DNSProvider, error) { if err != nil { return nil, fmt.Errorf("easydns: %w", err) } + config.Endpoint = endpoint values, err := env.Get(EnvToken, EnvKey) @@ -107,6 +112,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + if config.Endpoint != nil { client.BaseURL = config.Endpoint } @@ -183,15 +190,14 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), recordID) - - d.recordIDsMu.Lock() - defer delete(d.recordIDs, key) - d.recordIDsMu.Unlock() - if err != nil { return fmt.Errorf("easydns: %w", err) } + d.recordIDsMu.Lock() + delete(d.recordIDs, key) + d.recordIDsMu.Unlock() + return nil } diff --git a/providers/dns/easydns/easydns.toml b/providers/dns/easydns/easydns.toml index 4c775fb5a..307c86a09 100644 --- a/providers/dns/easydns/easydns.toml +++ b/providers/dns/easydns/easydns.toml @@ -7,7 +7,7 @@ Since = "v2.6.0" Example = ''' EASYDNS_TOKEN=xxx \ EASYDNS_KEY=yyy \ -lego --email you@example.com --dns easydns -d '*.example.com' -d example.com run +lego --dns easydns -d '*.example.com' -d example.com run ''' Additional = ''' @@ -20,11 +20,11 @@ To test with the sandbox environment set ```EASYDNS_ENDPOINT=https://sandbox.res EASYDNS_KEY = "API Key" [Configuration.Additional] EASYDNS_ENDPOINT = "The endpoint URL of the API Server" - EASYDNS_POLLING_INTERVAL = "Time between DNS propagation check" - EASYDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - EASYDNS_SEQUENCE_INTERVAL = "Time between sequential requests" - EASYDNS_TTL = "The TTL of the TXT record used for the DNS challenge" - EASYDNS_HTTP_TIMEOUT = "API request timeout" + EASYDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + EASYDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + EASYDNS_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" + EASYDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + EASYDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docs.sandbox.rest.easydns.net" diff --git a/providers/dns/easydns/easydns_test.go b/providers/dns/easydns/easydns_test.go index 972ff8cda..5517928d7 100644 --- a/providers/dns/easydns/easydns_test.go +++ b/providers/dns/easydns/easydns_test.go @@ -2,7 +2,6 @@ package easydns import ( "fmt" - "io" "net/http" "net/http/httptest" "net/url" @@ -10,12 +9,10 @@ import ( "time" "github.com/go-acme/lego/v4/platform/tester" - "github.com/stretchr/testify/assert" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -const authorizationHeader = "Authorization" - const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( @@ -24,26 +21,27 @@ var envTest = tester.NewEnvTest( EnvKey). WithDomain(envDomain) -func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + endpoint, err := url.Parse(server.URL) + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + config := NewDefaultConfig() + config.Token = "TOKEN" + config.Key = "SECRET" + config.Endpoint = endpoint + config.HTTPClient = server.Client() - endpoint, err := url.Parse(server.URL) - require.NoError(t, err) - - config := NewDefaultConfig() - config.Token = "TOKEN" - config.Key = "SECRET" - config.Endpoint = endpoint - config.HTTPClient = server.Client() - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - return provider, mux + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader(). + WithJSONHeaders(). + WithAuthorization("Basic VE9LRU46U0VDUkVU"), + servermock.CheckQueryParameter().Strict(). + With("format", "json")) } func TestNewDNSProvider(t *testing.T) { @@ -78,6 +76,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -145,78 +144,50 @@ func TestNewDNSProviderConfig(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - assert.Equal(t, "format=json", r.URL.RawQuery, "query") - assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) - - w.WriteHeader(http.StatusOK) - _, err := fmt.Fprintf(w, `{ - "msg": "string", - "status": 200, - "tm": 0, - "data": [{ - "id": "60898922", - "domain": "example.com", - "host": "hosta", - "ttl": "300", - "prio": "0", - "geozone_id": "0", - "type": "A", - "rdata": "1.2.3.4", - "last_mod": "2019-08-28 19:09:50" - }], - "count": 0, - "total": 0, - "start": 0, - "max": 0 -} -`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + provider := mockBuilder(). + Route("GET /zones/records/all/example.com", + servermock.RawStringResponse(`{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 } - }) - - mux.HandleFunc("/zones/records/add/example.com/TXT", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPut, r.Method, "method") - assert.Equal(t, "format=json", r.URL.RawQuery, "query") - assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type") - assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) - - reqBody, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - expectedReqBody := `{"domain":"example.com","host":"_acme-challenge","ttl":"120","prio":"0","type":"TXT","rdata":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"} -` - assert.Equal(t, expectedReqBody, string(reqBody)) - - w.WriteHeader(http.StatusCreated) - _, err = fmt.Fprintf(w, `{ - "msg": "OK", - "tm": 1554681934, - "data": { - "host": "_acme-challenge", - "geozone_id": 0, - "ttl": "120", - "prio": "0", - "rdata": "pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM", - "revoked": 0, - "id": "123456789", - "new_host": "_acme-challenge.example.com" - }, - "status": 201 - }`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + `), + servermock.CheckQueryParameter().Strict(). + With("format", "json")). + Route("PUT /zones/records/add/example.com/TXT", + servermock.RawStringResponse(`{ + "msg": "OK", + "tm": 1554681934, + "data": { + "host": "_acme-challenge", + "geozone_id": 0, + "ttl": "120", + "prio": "0", + "rdata": "pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM", + "revoked": 0, + "id": "123456789", + "new_host": "_acme-challenge.example.com" + }, + "status": 201 + }`), + servermock.CheckRequestJSONBody(`{"domain":"example.com","host":"_acme-challenge","ttl":"120","prio":"0","type":"TXT","rdata":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"} +`)). + Build(t) err := provider.Present("example.com", "token", "keyAuth") require.NoError(t, err) @@ -224,163 +195,116 @@ func TestDNSProvider_Present(t *testing.T) { } func TestDNSProvider_Cleanup_WhenRecordIdNotSet_NoOp(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - assert.Equal(t, "format=json", r.URL.RawQuery, "query") - assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) - - w.WriteHeader(http.StatusOK) - _, err := fmt.Fprintf(w, `{ - "msg": "string", - "status": 200, - "tm": 0, - "data": [{ - "id": "60898922", - "domain": "example.com", - "host": "hosta", - "ttl": "300", - "prio": "0", - "geozone_id": "0", - "type": "A", - "rdata": "1.2.3.4", - "last_mod": "2019-08-28 19:09:50" - }], - "count": 0, - "total": 0, - "start": 0, - "max": 0 -} -`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + provider := mockBuilder(). + Route("GET /zones/records/all/_acme-challenge.example.com", + servermock.RawStringResponse(`{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 + } + `)). + Build(t) err := provider.CleanUp("example.com", "token", "keyAuth") require.NoError(t, err) } func TestDNSProvider_Cleanup_WhenRecordIdSet_DeletesTxtRecord(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - assert.Equal(t, "format=json", r.URL.RawQuery, "query") - assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) - - w.WriteHeader(http.StatusOK) - _, err := fmt.Fprintf(w, `{ - "msg": "string", - "status": 200, - "tm": 0, - "data": [{ - "id": "60898922", - "domain": "example.com", - "host": "hosta", - "ttl": "300", - "prio": "0", - "geozone_id": "0", - "type": "A", - "rdata": "1.2.3.4", - "last_mod": "2019-08-28 19:09:50" - }], - "count": 0, - "total": 0, - "start": 0, - "max": 0 -} -`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - mux.HandleFunc("/zones/records/example.com/123456", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodDelete, r.Method, "method") - assert.Equal(t, "format=json", r.URL.RawQuery, "query") - assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) - - w.WriteHeader(http.StatusOK) - _, err := fmt.Fprintf(w, `{ - "msg": "OK", - "data": { - "domain": "example.com", - "id": "123456" - }, - "status": 200 - }`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + provider := mockBuilder(). + Route("GET /zones/records/all/_acme-challenge.example.com", + servermock.RawStringResponse(`{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 + } + `)). + Route("DELETE /zones/records/_acme-challenge.example.com/123456", + servermock.RawStringResponse(`{ + "msg": "OK", + "data": { + "domain": "example.com", + "id": "123456" + }, + "status": 200 + }`)). + Build(t) provider.recordIDs["_acme-challenge.example.com.|pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"] = "123456" + err := provider.CleanUp("example.com", "token", "keyAuth") require.NoError(t, err) } func TestDNSProvider_Cleanup_WhenHttpError_ReturnsError(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - assert.Equal(t, "format=json", r.URL.RawQuery, "query") - assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) - - w.WriteHeader(http.StatusOK) - _, err := fmt.Fprintf(w, `{ - "msg": "string", - "status": 200, - "tm": 0, - "data": [{ - "id": "60898922", - "domain": "example.com", - "host": "hosta", - "ttl": "300", - "prio": "0", - "geozone_id": "0", - "type": "A", - "rdata": "1.2.3.4", - "last_mod": "2019-08-28 19:09:50" - }], - "count": 0, - "total": 0, - "start": 0, - "max": 0 -} -`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - errorMessage := `{ "error": { "code": 406, "message": "Provided id is invalid or you do not have permission to access it." } }` - mux.HandleFunc("/zones/records/example.com/123456", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodDelete, r.Method, "method") - assert.Equal(t, "format=json", r.URL.RawQuery, "query") - assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) - w.WriteHeader(http.StatusNotAcceptable) - _, err := fmt.Fprint(w, errorMessage) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + provider := mockBuilder(). + Route("GET /zones/records/all/example.com", + servermock.RawStringResponse(`{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 +} +`)). + Route("DELETE /zones/records/example.com/123456", + servermock.RawStringResponse(errorMessage). + WithStatusCode(http.StatusNotAcceptable)). + Build(t) provider.recordIDs["_acme-challenge.example.com.|pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"] = "123456" + err := provider.CleanUp("example.com", "token", "keyAuth") + expectedError := fmt.Sprintf("easydns: unexpected status code: [status code: 406] body: %v", errorMessage) require.EqualError(t, err, expectedError) } @@ -391,6 +315,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -404,6 +329,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/easydns/internal/client.go b/providers/dns/easydns/internal/client.go index 3568eeea5..33d7c724e 100644 --- a/providers/dns/easydns/internal/client.go +++ b/providers/dns/easydns/internal/client.go @@ -26,7 +26,7 @@ type Client struct { } // NewClient Creates a new Client. -func NewClient(token string, key string) *Client { +func NewClient(token, key string) *Client { baseURL, _ := url.Parse(DefaultBaseURL) return &Client{ @@ -46,6 +46,7 @@ func (c *Client) ListZones(ctx context.Context, domain string) ([]ZoneRecord, er } response := &apiResponse[[]ZoneRecord]{} + err = c.do(req, response) if err != nil { return nil, err @@ -67,6 +68,7 @@ func (c *Client) AddRecord(ctx context.Context, domain string, record ZoneRecord } response := &apiResponse[*ZoneRecord]{} + err = c.do(req, response) if err != nil { return "", err diff --git a/providers/dns/easydns/internal/client_test.go b/providers/dns/easydns/internal/client_test.go index 030b28f34..bf4e1e45b 100644 --- a/providers/dns/easydns/internal/client_test.go +++ b/providers/dns/easydns/internal/client_test.go @@ -1,76 +1,36 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("tok", "k") + client.HTTPClient = server.Client() + client.BaseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - token, key, ok := req.BasicAuth() - if token != "tok" || key != "k" || !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if req.URL.Query().Get("format") != "json" { - http.Error(rw, fmt.Sprintf("invalid format: %s", req.URL.Query().Get("format")), http.StatusBadRequest) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client := NewClient("tok", "k") - client.HTTPClient = server.Client() - client.BaseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("tok", "k"), + ) } func TestClient_ListZones(t *testing.T) { - client := setupTest(t, http.MethodGet, "/zones/records/all/example.com", http.StatusOK, "list-zone.json") + client := mockBuilder(). + Route("GET /zones/records/all/example.com", servermock.ResponseFromFixture("list-zone.json")). + Build(t) - zones, err := client.ListZones(context.Background(), "example.com") + zones, err := client.ListZones(t.Context(), "example.com") require.NoError(t, err) expected := []ZoneRecord{{ @@ -88,14 +48,20 @@ func TestClient_ListZones(t *testing.T) { } func TestClient_ListZones_error(t *testing.T) { - client := setupTest(t, http.MethodGet, "/zones/records/all/example.com", http.StatusOK, "error1.json") + client := mockBuilder(). + Route("GET /zones/records/all/example.com", servermock.ResponseFromFixture("error1.json")). + Build(t) - _, err := client.ListZones(context.Background(), "example.com") + _, err := client.ListZones(t.Context(), "example.com") require.EqualError(t, err, "code 420: Enhance Your Calm. Rate limit exceeded (too many requests) OR you did NOT provide any credentials with your request!") } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, http.MethodPut, "/zones/records/add/example.com/TXT", http.StatusCreated, "add-record.json") + client := mockBuilder(). + Route("PUT /zones/records/add/example.com/TXT", + servermock.ResponseFromFixture("add-record.json").WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBody(`{"domain":"example.com","host":"test631","ttl":"300","prio":"0","type":"TXT","rdata":"txt"}`)). + Build(t) record := ZoneRecord{ Domain: "example.com", @@ -106,14 +72,17 @@ func TestClient_AddRecord(t *testing.T) { Priority: "0", } - recordID, err := client.AddRecord(context.Background(), "example.com", record) + recordID, err := client.AddRecord(t.Context(), "example.com", record) require.NoError(t, err) assert.Equal(t, "xxx", recordID) } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, http.MethodPut, "/zones/records/add/example.com/TXT", http.StatusCreated, "error1.json") + client := mockBuilder(). + Route("PUT /zones/records/add/example.com/TXT", + servermock.ResponseFromFixture("error1.json").WithStatusCode(http.StatusCreated)). + Build(t) record := ZoneRecord{ Domain: "example.com", @@ -124,13 +93,15 @@ func TestClient_AddRecord_error(t *testing.T) { Priority: "0", } - _, err := client.AddRecord(context.Background(), "example.com", record) + _, err := client.AddRecord(t.Context(), "example.com", record) require.EqualError(t, err, "code 420: Enhance Your Calm. Rate limit exceeded (too many requests) OR you did NOT provide any credentials with your request!") } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/zones/records/example.com/xxx", http.StatusOK, "") + client := mockBuilder(). + Route("DELETE /zones/records/example.com/xxx", nil). + Build(t) - err := client.DeleteRecord(context.Background(), "example.com", "xxx") + err := client.DeleteRecord(t.Context(), "example.com", "xxx") require.NoError(t, err) } diff --git a/providers/dns/edgecenter/edgecenter.go b/providers/dns/edgecenter/edgecenter.go new file mode 100644 index 000000000..cfc75b521 --- /dev/null +++ b/providers/dns/edgecenter/edgecenter.go @@ -0,0 +1,103 @@ +// Package edgecenter implements a DNS provider for solving the DNS-01 challenge using EdgeCenter. +package edgecenter + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/gcore" +) + +// Environment variables names. +const ( + envNamespace = "EDGECENTER_" + + EnvPermanentAPIToken = envNamespace + "PERMANENT_API_TOKEN" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +const defaultBaseURL = "https://api.edgecenter.ru/dns" + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config for DNSProvider. +type Config = gcore.Config + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, gcore.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, gcore.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), + }, + } +} + +// DNSProvider an implementation of challenge.Provider contract. +type DNSProvider struct { + prv challenge.ProviderTimeout +} + +// NewDNSProvider returns an instance of DNSProvider configured for G-Core DNS API. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvPermanentAPIToken) + if err != nil { + return nil, fmt.Errorf("edgecenter: %w", err) + } + + config := NewDefaultConfig() + config.APIToken = values[EnvPermanentAPIToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for G-Core DNS API. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("edgecenter: the configuration of the DNS provider is nil") + } + + provider, err := gcore.NewDNSProviderConfig(config, defaultBaseURL) + if err != nil { + return nil, fmt.Errorf("edgecenter: %w", err) + } + + return &DNSProvider{prv: provider}, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + err := d.prv.Present(domain, token, keyAuth) + if err != nil { + return fmt.Errorf("edgecenter: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + err := d.prv.CleanUp(domain, token, keyAuth) + if err != nil { + return fmt.Errorf("edgecenter: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.prv.Timeout() +} diff --git a/providers/dns/edgecenter/edgecenter.toml b/providers/dns/edgecenter/edgecenter.toml new file mode 100644 index 000000000..1c9e9b2a9 --- /dev/null +++ b/providers/dns/edgecenter/edgecenter.toml @@ -0,0 +1,22 @@ +Name = "EdgeCenter" +Description = '''''' +URL = "https://edgecenter.ru/dns" +Code = "edgecenter" +Since = "v4.29.0" + +Example = ''' +EDGECENTER_PERMANENT_API_TOKEN=xxxxx \ +lego --dns edgecenter -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + EDGECENTER_PERMANENT_API_TOKEN = "Permanent API token (https://edgecenter.ru/blog/permanent-api-token-explained/)" + [Configuration.Additional] + EDGECENTER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)" + EDGECENTER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 360)" + EDGECENTER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + EDGECENTER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" + +[Links] + API = "https://apidocs.edgecenter.ru/dns" diff --git a/providers/dns/edgecenter/edgecenter_test.go b/providers/dns/edgecenter/edgecenter_test.go new file mode 100644 index 000000000..e3ec43981 --- /dev/null +++ b/providers/dns/edgecenter/edgecenter_test.go @@ -0,0 +1,114 @@ +package edgecenter + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +var envTest = tester.NewEnvTest(EnvPermanentAPIToken).WithDomain(envNamespace + "DOMAIN") + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvPermanentAPIToken: "A", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvPermanentAPIToken: "", + }, + expected: "edgecenter: some credentials information are missing: EDGECENTER_PERMANENT_API_TOKEN", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.prv) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiToken string + expected string + }{ + { + desc: "success", + apiToken: "A", + }, + { + desc: "missing credentials", + expected: "edgecenter: incomplete credentials provided", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIToken = test.apiToken + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.prv) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/edgedns/edgedns.go b/providers/dns/edgedns/edgedns.go index 263ba0c39..b5f4b99c9 100644 --- a/providers/dns/edgedns/edgedns.go +++ b/providers/dns/edgedns/edgedns.go @@ -2,14 +2,18 @@ package edgedns import ( + "context" "errors" "fmt" + "net/http" "slices" "strings" "time" - configdns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2" - "github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid" + edgegriddns "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/dns" + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/edgegrid" + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/session" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" @@ -19,19 +23,24 @@ import ( const ( envNamespace = "AKAMAI_" - EnvEdgeRc = envNamespace + "EDGERC" - EnvEdgeRcSection = envNamespace + "EDGERC_SECTION" - - EnvHost = envNamespace + "HOST" - EnvClientToken = envNamespace + "CLIENT_TOKEN" - EnvClientSecret = envNamespace + "CLIENT_SECRET" - EnvAccessToken = envNamespace + "ACCESS_TOKEN" + EnvEdgeRc = envNamespace + "EDGERC" + EnvEdgeRcSection = envNamespace + "EDGERC_SECTION" + EnvAccountSwitchKey = envNamespace + "ACCOUNT_SWITCH_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +// Test Environment variables names (unused). +// TODO(ldez): must be moved into test files. +const ( + EnvHost = envNamespace + "HOST" + EnvClientToken = envNamespace + "CLIENT_TOKEN" + EnvClientSecret = envNamespace + "CLIENT_SECRET" + EnvAccessToken = envNamespace + "ACCESS_TOKEN" +) + const ( defaultPropagationTimeout = 3 * time.Minute defaultPollInterval = 15 * time.Second @@ -39,9 +48,12 @@ const ( const maxBody = 131072 +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { - edgegrid.Config + *edgegrid.Config + PropagationTimeout time.Duration PollingInterval time.Duration TTL int @@ -53,7 +65,7 @@ func NewDefaultConfig() *Config { TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, defaultPollInterval), - Config: edgegrid.Config{MaxBody: maxBody}, + Config: &edgegrid.Config{MaxBody: maxBody}, } } @@ -68,22 +80,27 @@ type DNSProvider struct { // 1. Section-specific environment variables `AKAMAI_{SECTION}_HOST`, `AKAMAI_{SECTION}_ACCESS_TOKEN`, `AKAMAI_{SECTION}_CLIENT_TOKEN`, `AKAMAI_{SECTION}_CLIENT_SECRET` where `{SECTION}` is specified using `AKAMAI_EDGERC_SECTION` // 2. If `AKAMAI_EDGERC_SECTION` is not defined or is set to `default`: Environment variables `AKAMAI_HOST`, `AKAMAI_ACCESS_TOKEN`, `AKAMAI_CLIENT_TOKEN`, `AKAMAI_CLIENT_SECRET` // 3. .edgerc file located at `AKAMAI_EDGERC` (defaults to `~/.edgerc`, sections can be specified using `AKAMAI_EDGERC_SECTION`) -// 4. Default environment variables: `AKAMAI_HOST`, `AKAMAI_ACCESS_TOKEN`, `AKAMAI_CLIENT_TOKEN`, `AKAMAI_CLIENT_SECRET` // // See also: https://developer.akamai.com/api/getting-started func NewDNSProvider() (*DNSProvider, error) { - config := NewDefaultConfig() - - rcPath := env.GetOrDefaultString(EnvEdgeRc, "") - rcSection := env.GetOrDefaultString(EnvEdgeRcSection, "") - - conf, err := edgegrid.Init(rcPath, rcSection) + conf, err := edgegrid.New( + edgegrid.WithEnv(true), + edgegrid.WithFile(env.GetOrDefaultString(EnvEdgeRc, "~/.edgerc")), + edgegrid.WithSection(env.GetOrDefaultString(EnvEdgeRcSection, "default")), + ) if err != nil { return nil, fmt.Errorf("edgedns: %w", err) } conf.MaxBody = maxBody + accountSwitchKey := env.GetOrDefaultString(EnvAccountSwitchKey, "") + + if accountSwitchKey != "" { + conf.AccountKey = accountSwitchKey + } + + config := NewDefaultConfig() config.Config = conf return NewDNSProviderConfig(config) @@ -95,7 +112,10 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("edgedns: the configuration of the DNS provider is nil") } - configdns.Init(config.Config) + err := config.Validate() + if err != nil { + return nil, fmt.Errorf("edgedns: %w", err) + } return &DNSProvider{config: config}, nil } @@ -108,14 +128,27 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) + sess, err := session.New(session.WithSigner(d.config)) + if err != nil { + return fmt.Errorf("edgedns: %w", err) + } + + client := edgegriddns.Client(sess) + zone, err := getZone(info.EffectiveFQDN) if err != nil { return fmt.Errorf("edgedns: %w", err) } - record, err := configdns.GetRecord(zone, info.EffectiveFQDN, "TXT") + record, err := client.GetRecord(ctx, edgegriddns.GetRecordRequest{ + Zone: zone, + Name: info.EffectiveFQDN, + RecordType: "TXT", + }) if err != nil && !isNotFound(err) { return fmt.Errorf("edgedns: %w", err) } @@ -135,7 +168,16 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { record.Target = append(record.Target, `"`+info.Value+`"`) record.TTL = d.config.TTL - err = record.Update(zone) + err = client.UpdateRecord(ctx, edgegriddns.UpdateRecordRequest{ + Record: &edgegriddns.RecordBody{ + Name: record.Name, + RecordType: record.RecordType, + TTL: record.TTL, + Active: record.Active, + Target: record.Target, + }, + Zone: zone, + }) if err != nil { return fmt.Errorf("edgedns: %w", err) } @@ -143,14 +185,16 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return nil } - record = &configdns.RecordBody{ - Name: info.EffectiveFQDN, - RecordType: "TXT", - TTL: d.config.TTL, - Target: []string{`"` + info.Value + `"`}, - } - - err = record.Save(zone) + err = client.CreateRecord(ctx, edgegriddns.CreateRecordRequest{ + Record: &edgegriddns.RecordBody{ + Name: info.EffectiveFQDN, + RecordType: "TXT", + TTL: d.config.TTL, + Target: []string{`"` + info.Value + `"`}, + }, + Zone: zone, + RecLock: nil, + }) if err != nil { return fmt.Errorf("edgedns: %w", err) } @@ -160,18 +204,32 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // CleanUp removes the record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) + sess, err := session.New(session.WithSigner(d.config)) + if err != nil { + return fmt.Errorf("edgedns: %w", err) + } + + client := edgegriddns.Client(sess) + zone, err := getZone(info.EffectiveFQDN) if err != nil { return fmt.Errorf("edgedns: %w", err) } - existingRec, err := configdns.GetRecord(zone, info.EffectiveFQDN, "TXT") + existingRec, err := client.GetRecord(ctx, edgegriddns.GetRecordRequest{ + Zone: zone, + Name: info.EffectiveFQDN, + RecordType: "TXT", + }) if err != nil { if isNotFound(err) { return nil } + return fmt.Errorf("edgedns: %w", err) } @@ -187,19 +245,21 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } - var newRData []string - for _, val := range existingRec.Target { - val = strings.Trim(val, `"`) - if val == info.Value { - continue - } - newRData = append(newRData, val) - } + newRData := filterRData(existingRec, info) if len(newRData) > 0 { existingRec.Target = newRData - err = existingRec.Update(zone) + err = client.UpdateRecord(ctx, edgegriddns.UpdateRecordRequest{ + Record: &edgegriddns.RecordBody{ + Name: existingRec.Name, + RecordType: existingRec.RecordType, + TTL: existingRec.TTL, + Active: existingRec.Active, + Target: existingRec.Target, + }, + Zone: zone, + }) if err != nil { return fmt.Errorf("edgedns: %w", err) } @@ -207,7 +267,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } - err = existingRec.Delete(zone) + err = client.DeleteRecord(ctx, edgegriddns.DeleteRecordRequest{ + Zone: zone, + Name: existingRec.Name, + RecordType: "TXT", + RecLock: nil, + }) if err != nil { return fmt.Errorf("edgedns: %w", err) } @@ -235,6 +300,22 @@ func isNotFound(err error) bool { return false } - var e configdns.ConfigDNSError - return errors.As(err, &e) && e.NotFound() + var e *edgegriddns.Error + + return errors.As(err, &e) && e.StatusCode == http.StatusNotFound +} + +func filterRData(existingRec *edgegriddns.GetRecordResponse, info dns01.ChallengeInfo) []string { + var newRData []string + + for _, val := range existingRec.Target { + val = strings.Trim(val, `"`) + if val == info.Value { + continue + } + + newRData = append(newRData, val) + } + + return newRData } diff --git a/providers/dns/edgedns/edgedns.toml b/providers/dns/edgedns/edgedns.toml index c01500112..7c7c5b3aa 100644 --- a/providers/dns/edgedns/edgedns.toml +++ b/providers/dns/edgedns/edgedns.toml @@ -12,7 +12,7 @@ AKAMAI_CLIENT_SECRET=abcdefghijklmnopqrstuvwxyz1234567890ABCDEFG= \ AKAMAI_CLIENT_TOKEN=akab-mnbvcxzlkjhgfdsapoiuytrewq1234567 \ AKAMAI_HOST=akab-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.luna.akamaiapis.net \ AKAMAI_ACCESS_TOKEN=akab-1234567890qwerty-asdfghjklzxcvtnu \ -lego --email you@example.com --dns edgedns -d '*.example.com' -d example.com run +lego --dns edgedns -d '*.example.com' -d example.com run ''' Additional = ''' @@ -42,6 +42,7 @@ See also: - [.edgerc Format](https://developer.akamai.com/legacy/introduction/Conf_Client.html#edgercformat) - [API Client Authentication](https://developer.akamai.com/legacy/introduction/Client_Auth.html) - [Config from Env](https://github.com/akamai/AkamaiOPEN-edgegrid-golang/blob/master/pkg/edgegrid/config.go#L118) +- [Manage many accounts](https://techdocs.akamai.com/developer/docs/manage-many-accounts-with-one-api-client) ''' [Configuration] @@ -53,9 +54,10 @@ See also: AKAMAI_EDGERC = "Path to the .edgerc file, managed by the Akamai EdgeGrid client" AKAMAI_EDGERC_SECTION = "Configuration section, managed by the Akamai EdgeGrid client" [Configuration.Additional] - AKAMAI_POLLING_INTERVAL = "Time between DNS propagation check. Default: 15 seconds" - AKAMAI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation. Default: 3 minutes" - AKAMAI_TTL = "The TTL of the TXT record used for the DNS challenge" + AKAMAI_ACCOUNT_SWITCH_KEY = "Target account ID when the DNS zone and credentials belong to different accounts" + AKAMAI_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 15)" + AKAMAI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 180)" + AKAMAI_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" [Links] API = "https://developer.akamai.com/api/cloud_security/edge_dns_zone_management/v2.html" diff --git a/providers/dns/edgedns/edgedns_integration_test.go b/providers/dns/edgedns/edgedns_integration_test.go index e1b3bb7cf..d20b8e5aa 100644 --- a/providers/dns/edgedns/edgedns_integration_test.go +++ b/providers/dns/edgedns/edgedns_integration_test.go @@ -1,11 +1,13 @@ package edgedns import ( + "context" "fmt" "testing" "time" - configdns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2" + edgegriddns "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/dns" + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/session" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -17,6 +19,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -34,6 +37,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -69,10 +73,21 @@ func TestLiveTTL(t *testing.T) { zone, err := getZone(fqdn) require.NoError(t, err) - resourceRecordSets, err := configdns.GetRecordList(zone, fqdn, "TXT") + ctx := context.Background() + + sess, err := session.New(session.WithSigner(provider.config)) require.NoError(t, err) - for i, rrset := range resourceRecordSets.Recordsets { + client := edgegriddns.Client(sess) + + resourceRecordSets, err := client.GetRecordList(ctx, edgegriddns.GetRecordListRequest{ + Zone: zone, + RecordType: "TXT", + }) + + require.NoError(t, err) + + for i, rrset := range resourceRecordSets.RecordSets { if rrset.Name != fqdn { continue } diff --git a/providers/dns/edgedns/edgedns_test.go b/providers/dns/edgedns/edgedns_test.go index 9bb76580b..a64efd6e2 100644 --- a/providers/dns/edgedns/edgedns_test.go +++ b/providers/dns/edgedns/edgedns_test.go @@ -1,12 +1,10 @@ package edgedns import ( - "os" "testing" "time" - configdns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2" - "github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid" + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/edgegrid" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" @@ -21,10 +19,14 @@ const ( ) var envTest = tester.NewEnvTest( + EnvTTL, + EnvPollingInterval, + EnvPropagationTimeout, EnvHost, EnvClientToken, EnvClientSecret, EnvAccessToken, + EnvAccountSwitchKey, EnvEdgeRc, EnvEdgeRcSection, envTestHost, @@ -34,7 +36,7 @@ var envTest = tester.NewEnvTest( WithDomain(envDomain). WithLiveTestRequirements(EnvHost, EnvClientToken, EnvClientSecret, EnvAccessToken, envDomain) -func TestNewDNSProvider_FromEnv(t *testing.T) { +func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string @@ -49,13 +51,31 @@ func TestNewDNSProvider_FromEnv(t *testing.T) { EnvClientSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", EnvAccessToken: "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx", }, - expectedConfig: &edgegrid.Config{ - Host: "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net", - ClientToken: "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx", - ClientSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - AccessToken: "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx", - MaxBody: maxBody, + expectedConfig: newEdgeConfig(func(config *edgegrid.Config) { + config.Host = "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net" + config.ClientToken = "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx" + config.ClientSecret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + config.AccessToken = "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx" + config.MaxBody = maxBody + }, edgegrid.WithEnv(true), edgegrid.WithFile("/dev/null")), + }, + { + desc: "with account switch key", + envVars: map[string]string{ + EnvHost: "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net", + EnvClientToken: "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx", + EnvClientSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + EnvAccessToken: "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx", + EnvAccountSwitchKey: "F-AC-1234", }, + expectedConfig: newEdgeConfig(func(config *edgegrid.Config) { + config.Host = "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net" + config.ClientToken = "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx" + config.ClientSecret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + config.AccessToken = "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx" + config.MaxBody = maxBody + config.AccountKey = "F-AC-1234" + }, edgegrid.WithEnv(true), edgegrid.WithFile("/dev/null")), }, { desc: "with section", @@ -66,17 +86,17 @@ func TestNewDNSProvider_FromEnv(t *testing.T) { envTestClientSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", envTestAccessToken: "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx", }, - expectedConfig: &edgegrid.Config{ - Host: "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net", - ClientToken: "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx", - ClientSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - AccessToken: "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx", - MaxBody: maxBody, - }, + expectedConfig: newEdgeConfig(func(config *edgegrid.Config) { + config.Host = "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net" + config.ClientToken = "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx" + config.ClientSecret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + config.AccessToken = "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx" + config.MaxBody = maxBody + }, edgegrid.WithEnv(true), edgegrid.WithFile("/dev/null"), edgegrid.WithSection("test")), }, { desc: "missing credentials", - expectedErr: "edgedns: Unable to create instance using environment or .edgerc file", + expectedErr: `edgedns: unable to load config from environment or .edgerc file`, }, { desc: "missing host", @@ -86,7 +106,7 @@ func TestNewDNSProvider_FromEnv(t *testing.T) { EnvClientSecret: "C", EnvAccessToken: "D", }, - expectedErr: "edgedns: Unable to create instance using environment or .edgerc file", + expectedErr: `edgedns: unable to load config from environment or .edgerc file`, }, { desc: "missing client token", @@ -96,7 +116,7 @@ func TestNewDNSProvider_FromEnv(t *testing.T) { EnvClientSecret: "C", EnvAccessToken: "D", }, - expectedErr: "edgedns: Fatal missing required environment variables: [AKAMAI_CLIENT_TOKEN]", + expectedErr: `edgedns: unable to load config from environment or .edgerc file`, }, { desc: "missing client secret", @@ -106,7 +126,7 @@ func TestNewDNSProvider_FromEnv(t *testing.T) { EnvClientSecret: "", EnvAccessToken: "D", }, - expectedErr: "edgedns: Fatal missing required environment variables: [AKAMAI_CLIENT_SECRET]", + expectedErr: `edgedns: unable to load config from environment or .edgerc file`, }, { desc: "missing access token", @@ -116,18 +136,20 @@ func TestNewDNSProvider_FromEnv(t *testing.T) { EnvClientSecret: "C", EnvAccessToken: "", }, - expectedErr: "edgedns: Fatal missing required environment variables: [AKAMAI_ACCESS_TOKEN]", + expectedErr: `edgedns: unable to load config from environment or .edgerc file`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() if test.envVars == nil { test.envVars = map[string]string{} } + test.envVars[EnvEdgeRc] = "/dev/null" envTest.Apply(test.envVars) @@ -135,7 +157,7 @@ func TestNewDNSProvider_FromEnv(t *testing.T) { p, err := NewDNSProvider() if test.expectedErr != "" { - require.EqualError(t, err, test.expectedErr) + require.ErrorContains(t, err, test.expectedErr) return } @@ -144,13 +166,63 @@ func TestNewDNSProvider_FromEnv(t *testing.T) { require.NotNil(t, p.config) if test.expectedConfig != nil { - require.Equal(t, *test.expectedConfig, configdns.Config) + require.Equal(t, test.expectedConfig, p.config.Config) } }) } } -func TestDNSProvider_findZone(t *testing.T) { +func TestNewDefaultConfig(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected *Config + }{ + { + desc: "default configuration", + expected: &Config{ + TTL: dns01.DefaultTTL, + PropagationTimeout: 3 * time.Minute, + PollingInterval: 15 * time.Second, + Config: &edgegrid.Config{ + MaxBody: maxBody, + }, + }, + }, + { + desc: "custom values", + envVars: map[string]string{ + EnvTTL: "99", + EnvPropagationTimeout: "60", + EnvPollingInterval: "60", + }, + expected: &Config{ + TTL: 99, + PropagationTimeout: 60 * time.Second, + PollingInterval: 60 * time.Second, + Config: &edgegrid.Config{ + MaxBody: maxBody, + }, + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + config := NewDefaultConfig() + + require.Equal(t, test.expected, config) + }) + } +} + +func Test_findZone(t *testing.T) { testCases := []struct { desc string domain string @@ -179,53 +251,7 @@ func TestDNSProvider_findZone(t *testing.T) { } } -func TestNewDefaultConfig(t *testing.T) { - defer envTest.RestoreEnv() - - testCases := []struct { - desc string - envVars map[string]string - expected *Config - }{ - { - desc: "default configuration", - expected: &Config{ - TTL: dns01.DefaultTTL, - PropagationTimeout: 3 * time.Minute, - PollingInterval: 15 * time.Second, - Config: edgegrid.Config{ - MaxBody: maxBody, - }, - }, - }, - { - desc: "custom values", - envVars: map[string]string{ - EnvTTL: "99", - EnvPropagationTimeout: "60", - EnvPollingInterval: "60", - }, - expected: &Config{ - TTL: 99, - PropagationTimeout: 60 * time.Second, - PollingInterval: 60 * time.Second, - Config: edgegrid.Config{ - MaxBody: maxBody, - }, - }, - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - envTest.ClearEnv() - for key, value := range test.envVars { - os.Setenv(key, value) - } - - config := NewDefaultConfig() - - require.Equal(t, test.expected, config) - }) - } +func newEdgeConfig(opts ...edgegrid.Option) *edgegrid.Config { + config, _ := edgegrid.New(opts...) + return config } diff --git a/providers/dns/edgeone/edgeone.go b/providers/dns/edgeone/edgeone.go new file mode 100644 index 000000000..6931c6715 --- /dev/null +++ b/providers/dns/edgeone/edgeone.go @@ -0,0 +1,203 @@ +// Package edgeone implements a DNS provider for solving the DNS-01 challenge using Tencent EdgeOne. +package edgeone + +import ( + "context" + "errors" + "fmt" + "math" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" + teo "github.com/go-acme/tencentedgdeone/v20220901" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" + "golang.org/x/net/idna" +) + +// Environment variables names. +const ( + envNamespace = "EDGEONE_" + + EnvSecretID = envNamespace + "SECRET_ID" + EnvSecretKey = envNamespace + "SECRET_KEY" + EnvRegion = envNamespace + "REGION" + EnvSessionToken = envNamespace + "SESSION_TOKEN" + EnvZonesMapping = envNamespace + "ZONES_MAPPING" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + SecretID string + SecretKey string + Region string + SessionToken string + + ZonesMapping map[string]string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPTimeout time.Duration +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, 60), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 20*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 30*time.Second), + HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *teo.Client + + recordIDs map[string]*string + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for Tencent EdgeOne. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvSecretID, EnvSecretKey) + if err != nil { + return nil, fmt.Errorf("edgeone: %w", err) + } + + config := NewDefaultConfig() + config.SecretID = values[EnvSecretID] + config.SecretKey = values[EnvSecretKey] + config.Region = env.GetOrDefaultString(EnvRegion, "") + config.SessionToken = env.GetOrDefaultString(EnvSessionToken, "") + + mapping := env.GetOrDefaultString(EnvZonesMapping, "") + if mapping != "" { + config.ZonesMapping, err = env.ParsePairs(mapping) + if err != nil { + return nil, fmt.Errorf("edgeone: zones mapping: %w", err) + } + } + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Tencent EdgeOne. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("edgeone: the configuration of the DNS provider is nil") + } + + var credential *common.Credential + + switch { + case config.SecretID != "" && config.SecretKey != "" && config.SessionToken != "": + credential = common.NewTokenCredential(config.SecretID, config.SecretKey, config.SessionToken) + case config.SecretID != "" && config.SecretKey != "": + credential = common.NewCredential(config.SecretID, config.SecretKey) + default: + return nil, errors.New("edgeone: credentials missing") + } + + cpf := profile.NewClientProfile() + cpf.HttpProfile.Endpoint = "teo.intl.tencentcloudapi.com" + cpf.HttpProfile.ReqTimeout = int(math.Round(config.HTTPTimeout.Seconds())) + + client, err := teo.NewClient(credential, config.Region, cpf) + if err != nil { + return nil, fmt.Errorf("edgeone: %w", err) + } + + return &DNSProvider{ + config: config, + client: client, + recordIDs: map[string]*string{}, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx := context.Background() + + zoneID, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("edgeone: failed to get hosted zone: %w", err) + } + + punnyCoded, err := idna.ToASCII(dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("edgeone: fail to convert punycode: %w", err) + } + + request := teo.NewCreateDnsRecordRequest() + request.Name = ptr.Pointer(punnyCoded) + request.ZoneId = zoneID + request.Type = ptr.Pointer("TXT") + request.Content = ptr.Pointer(info.Value) + request.TTL = ptr.Pointer(int64(d.config.TTL)) + + nr, err := teo.CreateDnsRecordWithContext(ctx, d.client, request) + if err != nil { + return fmt.Errorf("edgeone: API call failed: %w", err) + } + + d.recordIDsMu.Lock() + d.recordIDs[token] = nr.Response.RecordId + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx := context.Background() + + zoneID, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("edgeone: failed to get hosted zone: %w", err) + } + + // get the record's unique ID from when we created it + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + + if !ok { + return fmt.Errorf("edgeone: unknown record ID for '%s'", info.EffectiveFQDN) + } + + request := teo.NewDeleteDnsRecordsRequest() + request.ZoneId = zoneID + request.RecordIds = []*string{recordID} + + _, err = teo.DeleteDnsRecordsWithContext(ctx, d.client, request) + if err != nil { + return fmt.Errorf("edgeone: delete record failed: %w", err) + } + + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/edgeone/edgeone.toml b/providers/dns/edgeone/edgeone.toml new file mode 100644 index 000000000..05b8bc516 --- /dev/null +++ b/providers/dns/edgeone/edgeone.toml @@ -0,0 +1,28 @@ +Name = "Tencent EdgeOne" +Description = '''''' +URL = "https://edgeone.ai" +Code = "edgeone" +Since = "v4.26.0" + +Example = ''' +EDGEONE_SECRET_ID=abcdefghijklmnopqrstuvwx \ +EDGEONE_SECRET_KEY=your-secret-key \ +lego --dns edgeone -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + EDGEONE_SECRET_ID = "Access key ID" + EDGEONE_SECRET_KEY = "Access Key secret" + [Configuration.Additional] + EDGEONE_SESSION_TOKEN = "Access Key token" + EDGEONE_REGION = "Region" + EDGEONE_ZONES_MAPPING = "Mapping between DNS zones and site IDs. (ex: 'example.org:id1,example.com:id2')" + EDGEONE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)" + EDGEONE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 1200)" + EDGEONE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + EDGEONE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://edgeone.ai/document/50454#dns-record-apis" + GoClient = "https://github.com/tencentcloud/tencentcloud-sdk-go" diff --git a/providers/dns/edgeone/edgeone_test.go b/providers/dns/edgeone/edgeone_test.go new file mode 100644 index 000000000..7bd4f6f6d --- /dev/null +++ b/providers/dns/edgeone/edgeone_test.go @@ -0,0 +1,170 @@ +package edgeone + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvSecretID, + EnvSecretKey, + EnvZonesMapping, +).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvSecretID: "123", + EnvSecretKey: "456", + }, + }, + { + desc: "success with zones mapping", + envVars: map[string]string{ + EnvSecretID: "123", + EnvSecretKey: "456", + EnvZonesMapping: "example.org:id1,example.com:id2", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvSecretID: "", + EnvSecretKey: "", + }, + expected: "edgeone: some credentials information are missing: EDGEONE_SECRET_ID,EDGEONE_SECRET_KEY", + }, + { + desc: "missing access id", + envVars: map[string]string{ + EnvSecretID: "", + EnvSecretKey: "456", + }, + expected: "edgeone: some credentials information are missing: EDGEONE_SECRET_ID", + }, + { + desc: "missing secret key", + envVars: map[string]string{ + EnvSecretID: "123", + EnvSecretKey: "", + }, + expected: "edgeone: some credentials information are missing: EDGEONE_SECRET_KEY", + }, + { + desc: "invalid mapping", + envVars: map[string]string{ + EnvSecretID: "123", + EnvSecretKey: "456", + EnvZonesMapping: "example.org:id1,example.com", + }, + expected: "edgeone: zones mapping: incorrect pair: example.com", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + secretID string + secretKey string + expected string + }{ + { + desc: "success", + secretID: "123", + secretKey: "456", + }, + { + desc: "missing credentials", + expected: "edgeone: credentials missing", + }, + { + desc: "missing secret id", + secretKey: "456", + expected: "edgeone: credentials missing", + }, + { + desc: "missing secret key", + secretID: "123", + expected: "edgeone: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.SecretID = test.secretID + config.SecretKey = test.secretKey + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/edgeone/wrapper.go b/providers/dns/edgeone/wrapper.go new file mode 100644 index 000000000..53fae9427 --- /dev/null +++ b/providers/dns/edgeone/wrapper.go @@ -0,0 +1,58 @@ +package edgeone + +import ( + "context" + "fmt" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" + teo "github.com/go-acme/tencentedgdeone/v20220901" +) + +func (d *DNSProvider) getHostedZoneID(ctx context.Context, domain string) (*string, error) { + authZone, err := dns01.FindZoneByFqdn(domain) + if err != nil { + return nil, fmt.Errorf("could not find zone: %w", err) + } + + if d.config.ZonesMapping != nil { + zoneID, ok := d.config.ZonesMapping[authZone] + if ok { + return ptr.Pointer(zoneID), nil + } + } + + request := teo.NewDescribeZonesRequest() + + var zones []*teo.Zone + + for { + response, err := teo.DescribeZonesWithContext(ctx, d.client, request) + if err != nil { + return nil, fmt.Errorf("API call failed: %w", err) + } + + zones = append(zones, response.Response.Zones...) + + if int64(len(zones)) >= ptr.Deref(response.Response.TotalCount) { + break + } + + request.Offset = ptr.Pointer(int64(len(zones))) + } + + var hostedZone *teo.Zone + + for _, zone := range zones { + unfqdn := dns01.UnFqdn(authZone) + if ptr.Deref(zone.ZoneName) == unfqdn { + hostedZone = zone + } + } + + if hostedZone == nil { + return nil, fmt.Errorf("zone %s not found for domain %s", authZone, domain) + } + + return hostedZone.ZoneId, nil +} diff --git a/providers/dns/efficientip/efficientip.go b/providers/dns/efficientip/efficientip.go index 6d639bce1..81b4530b7 100644 --- a/providers/dns/efficientip/efficientip.go +++ b/providers/dns/efficientip/efficientip.go @@ -9,9 +9,11 @@ import ( "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/efficientip/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -30,6 +32,8 @@ const ( EnvInsecureSkipVerify = envNamespace + "INSECURE_SKIP_VERIFY" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Username string @@ -88,12 +92,15 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config.Username == "" { return nil, errors.New("efficientip: missing username") } + if config.Password == "" { return nil, errors.New("efficientip: missing password") } + if config.Hostname == "" { return nil, errors.New("efficientip: missing hostname") } + if config.DNSName == "" { return nil, errors.New("efficientip: missing dnsname") } @@ -110,6 +117,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { } } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } diff --git a/providers/dns/efficientip/efficientip.toml b/providers/dns/efficientip/efficientip.toml index f03a8026f..6e1874319 100644 --- a/providers/dns/efficientip/efficientip.toml +++ b/providers/dns/efficientip/efficientip.toml @@ -9,7 +9,7 @@ EFFICIENTIP_USERNAME="user" \ EFFICIENTIP_PASSWORD="secret" \ EFFICIENTIP_HOSTNAME="ipam.example.org" \ EFFICIENTIP_DNS_NAME="dns.smart" \ -lego --email you@example.com --dns efficientip -d '*.example.com' -d example.com run +lego --dns efficientip -d '*.example.com' -d example.com run ''' [Configuration] @@ -21,7 +21,6 @@ lego --email you@example.com --dns efficientip -d '*.example.com' -d example.com [Configuration.Additional] EFFICIENTIP_INSECURE_SKIP_VERIFY = "Whether or not to verify EfficientIP API certificate" EFFICIENTIP_VIEW_NAME = "View name (ex: external)" - EFFICIENTIP_POLLING_INTERVAL = "Time between DNS propagation check" - EFFICIENTIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - EFFICIENTIP_TTL = "The TTL of the TXT record used for the DNS challenge" - EFFICIENTIP_HTTP_TIMEOUT = "API request timeout" + EFFICIENTIP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + EFFICIENTIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + EFFICIENTIP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" diff --git a/providers/dns/efficientip/efficientip_test.go b/providers/dns/efficientip/efficientip_test.go index 3ee2da777..c2751a79b 100644 --- a/providers/dns/efficientip/efficientip_test.go +++ b/providers/dns/efficientip/efficientip_test.go @@ -83,6 +83,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -178,6 +179,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -191,6 +193,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/efficientip/internal/client.go b/providers/dns/efficientip/internal/client.go index 2fea76a13..5ccdf3973 100644 --- a/providers/dns/efficientip/internal/client.go +++ b/providers/dns/efficientip/internal/client.go @@ -22,7 +22,7 @@ type Client struct { password string } -func NewClient(hostname string, username string, password string) *Client { +func NewClient(hostname, username, password string) *Client { baseURL, _ := url.Parse(fmt.Sprintf("https://%s/rest/", hostname)) return &Client{ @@ -33,7 +33,7 @@ func NewClient(hostname string, username string, password string) *Client { } } -func (c Client) ListRecords(ctx context.Context) ([]ResourceRecord, error) { +func (c *Client) ListRecords(ctx context.Context) ([]ResourceRecord, error) { endpoint := c.baseURL.JoinPath("dns_rr_list") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -51,7 +51,7 @@ func (c Client) ListRecords(ctx context.Context) ([]ResourceRecord, error) { return result, nil } -func (c Client) GetRecord(ctx context.Context, id string) (*ResourceRecord, error) { +func (c *Client) GetRecord(ctx context.Context, id string) (*ResourceRecord, error) { endpoint := c.baseURL.JoinPath("dns_rr_info") query := endpoint.Query() @@ -77,7 +77,7 @@ func (c Client) GetRecord(ctx context.Context, id string) (*ResourceRecord, erro return &result[0], nil } -func (c Client) AddRecord(ctx context.Context, record ResourceRecord) (*BaseOutput, error) { +func (c *Client) AddRecord(ctx context.Context, record ResourceRecord) (*BaseOutput, error) { endpoint := c.baseURL.JoinPath("dns_rr_add") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) @@ -99,7 +99,7 @@ func (c Client) AddRecord(ctx context.Context, record ResourceRecord) (*BaseOutp return &result[0], nil } -func (c Client) DeleteRecord(ctx context.Context, params DeleteInputParameters) (*BaseOutput, error) { +func (c *Client) DeleteRecord(ctx context.Context, params DeleteInputParameters) (*BaseOutput, error) { endpoint := c.baseURL.JoinPath("dns_rr_delete") // (rr_id || (rr_name && (dns_id || dns_name || hostaddr))) @@ -108,6 +108,7 @@ func (c Client) DeleteRecord(ctx context.Context, params DeleteInputParameters) if err != nil { return nil, fmt.Errorf("query parameters: %w", err) } + endpoint.RawQuery = v.Encode() req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) @@ -129,7 +130,7 @@ func (c Client) DeleteRecord(ctx context.Context, params DeleteInputParameters) return &result[0], nil } -func (c Client) do(req *http.Request, result any) error { +func (c *Client) do(req *http.Request, result any) error { req.SetBasicAuth(c.username, c.password) req.Header.Set("cache-control", "no-cache") @@ -200,6 +201,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response APIError + err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/efficientip/internal/client_test.go b/providers/dns/efficientip/internal/client_test.go index a766c9085..5d68b7d7f 100644 --- a/providers/dns/efficientip/internal/client_test.go +++ b/providers/dns/efficientip/internal/client_test.go @@ -1,80 +1,38 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + srvURL, _ := url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client := NewClient(srvURL.Host, "user", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - username, password, ok := req.BasicAuth() - if !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if username != "user" { - http.Error(rw, fmt.Sprintf("username: want %s got %s", username, "user"), http.StatusUnauthorized) - return - } - - if password != "secret" { - http.Error(rw, fmt.Sprintf("password: want %s got %s", password, "secret"), http.StatusUnauthorized) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - srvURL, _ := url.Parse(server.URL) - - client := NewClient(srvURL.Host, "user", "secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("user", "secret"), + ) } func TestListRecords(t *testing.T) { - client := setupTest(t, http.MethodGet, "/dns_rr_list", http.StatusOK, "dns_rr_list.json") + client := mockBuilder(). + Route("GET /dns_rr_list", servermock.ResponseFromFixture("dns_rr_list.json")). + Build(t) - ctx := context.Background() - - records, err := client.ListRecords(ctx) + records, err := client.ListRecords(t.Context()) require.NoError(t, err) expected := []ResourceRecord{ @@ -337,11 +295,13 @@ func TestListRecords(t *testing.T) { } func TestGetRecord(t *testing.T) { - client := setupTest(t, http.MethodGet, "/dns_rr_info", http.StatusOK, "dns_rr_info.json") + client := mockBuilder(). + Route("GET /dns_rr_info", servermock.ResponseFromFixture("dns_rr_info.json"), + servermock.CheckQueryParameter().Strict(). + With("rr_id", "239")). + Build(t) - ctx := context.Background() - - record, err := client.GetRecord(ctx, "239") + record, err := client.GetRecord(t.Context(), "239") require.NoError(t, err) expected := &ResourceRecord{ @@ -384,9 +344,11 @@ func TestGetRecord(t *testing.T) { } func TestAddRecord(t *testing.T) { - client := setupTest(t, http.MethodPost, "/dns_rr_add", http.StatusCreated, "dns_rr_add.json") - - ctx := context.Background() + client := mockBuilder(). + Route("POST /dns_rr_add", + servermock.ResponseFromFixture("dns_rr_add.json").WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBody(`{"dns_name":"dns.smart","dnsview_name":"external","rr_name":"test.example.com","rr_type":"TXT","value1":"test"}`)). + Build(t) r := ResourceRecord{ RRName: "test.example.com", @@ -396,7 +358,7 @@ func TestAddRecord(t *testing.T) { DNSViewName: "external", } - resp, err := client.AddRecord(ctx, r) + resp, err := client.AddRecord(t.Context(), r) require.NoError(t, err) expected := &BaseOutput{RetOID: "239"} @@ -405,11 +367,13 @@ func TestAddRecord(t *testing.T) { } func TestDeleteRecord(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns_rr_delete", http.StatusOK, "dns_rr_delete.json") + client := mockBuilder(). + Route("DELETE /dns_rr_delete", servermock.ResponseFromFixture("dns_rr_delete.json"), + servermock.CheckQueryParameter().Strict(). + With("rr_id", "251")). + Build(t) - ctx := context.Background() - - resp, err := client.DeleteRecord(ctx, DeleteInputParameters{RRID: "251"}) + resp, err := client.DeleteRecord(t.Context(), DeleteInputParameters{RRID: "251"}) require.NoError(t, err) expected := &BaseOutput{RetOID: "251"} @@ -418,10 +382,11 @@ func TestDeleteRecord(t *testing.T) { } func TestDeleteRecord_error(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns_rr_delete", http.StatusBadRequest, "dns_rr_delete-error.json") + client := mockBuilder(). + Route("DELETE /dns_rr_delete", + servermock.ResponseFromFixture("dns_rr_delete-error.json").WithStatusCode(http.StatusBadRequest)). + Build(t) - ctx := context.Background() - - _, err := client.DeleteRecord(ctx, DeleteInputParameters{RRID: "251"}) + _, err := client.DeleteRecord(t.Context(), DeleteInputParameters{RRID: "251"}) require.ErrorAs(t, err, &APIError{}) } diff --git a/providers/dns/epik/epik.go b/providers/dns/epik/epik.go index 4d4fb8c73..ef5de3c4b 100644 --- a/providers/dns/epik/epik.go +++ b/providers/dns/epik/epik.go @@ -9,9 +9,11 @@ import ( "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/epik/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -26,6 +28,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Signature string @@ -83,6 +87,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } diff --git a/providers/dns/epik/epik.toml b/providers/dns/epik/epik.toml index d0f1fda03..faf453581 100644 --- a/providers/dns/epik/epik.toml +++ b/providers/dns/epik/epik.toml @@ -6,17 +6,17 @@ Since = "v4.5.0" Example = ''' EPIK_SIGNATURE=xxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns epik -d '*.example.com' -d example.com run +lego --dns epik -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] EPIK_SIGNATURE = "Epik API signature (https://registrar.epik.com/account/api-settings/)" [Configuration.Additional] - EPIK_POLLING_INTERVAL = "Time between DNS propagation check" - EPIK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - EPIK_TTL = "The TTL of the TXT record used for the DNS challenge" - EPIK_HTTP_TIMEOUT = "API request timeout" + EPIK_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + EPIK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + EPIK_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" + EPIK_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docs-userapi.epik.com/v2/" diff --git a/providers/dns/epik/epik_test.go b/providers/dns/epik/epik_test.go index c0cd3d43b..b8b3c5c43 100644 --- a/providers/dns/epik/epik_test.go +++ b/providers/dns/epik/epik_test.go @@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -92,6 +93,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -105,6 +107,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/epik/internal/client.go b/providers/dns/epik/internal/client.go index 9a5385453..2c3373953 100644 --- a/providers/dns/epik/internal/client.go +++ b/providers/dns/epik/internal/client.go @@ -37,7 +37,7 @@ func NewClient(signature string) *Client { // GetDNSRecords gets DNS records for a domain. // https://docs.userapi.epik.com/v2/#/DNS%20Host%20Records/getDnsRecord -func (c Client) GetDNSRecords(ctx context.Context, domain string) ([]Record, error) { +func (c *Client) GetDNSRecords(ctx context.Context, domain string) ([]Record, error) { endpoint := c.createEndpoint(domain, url.Values{}) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -46,6 +46,7 @@ func (c Client) GetDNSRecords(ctx context.Context, domain string) ([]Record, err } var data GetDNSRecordResponse + err = c.do(req, &data) if err != nil { return nil, err @@ -56,7 +57,7 @@ func (c Client) GetDNSRecords(ctx context.Context, domain string) ([]Record, err // CreateHostRecord creates a record for a domain. // https://docs.userapi.epik.com/v2/#/DNS%20Host%20Records/createHostRecord -func (c Client) CreateHostRecord(ctx context.Context, domain string, record RecordRequest) (*Data, error) { +func (c *Client) CreateHostRecord(ctx context.Context, domain string, record RecordRequest) (*Data, error) { endpoint := c.createEndpoint(domain, url.Values{}) payload := CreateHostRecords{Payload: record} @@ -67,6 +68,7 @@ func (c Client) CreateHostRecord(ctx context.Context, domain string, record Reco } var data Data + err = c.do(req, &data) if err != nil { return nil, err @@ -77,7 +79,7 @@ func (c Client) CreateHostRecord(ctx context.Context, domain string, record Reco // RemoveHostRecord removes a record for a domain. // https://docs.userapi.epik.com/v2/#/DNS%20Host%20Records/removeHostRecord -func (c Client) RemoveHostRecord(ctx context.Context, domain string, recordID string) (*Data, error) { +func (c *Client) RemoveHostRecord(ctx context.Context, domain, recordID string) (*Data, error) { params := url.Values{} params.Set("ID", recordID) @@ -89,6 +91,7 @@ func (c Client) RemoveHostRecord(ctx context.Context, domain string, recordID st } var data Data + err = c.do(req, &data) if err != nil { return nil, err @@ -97,7 +100,7 @@ func (c Client) RemoveHostRecord(ctx context.Context, domain string, recordID st return &data, nil } -func (c Client) do(req *http.Request, result any) error { +func (c *Client) do(req *http.Request, result any) error { useragent.SetHeader(req.Header) resp, err := c.HTTPClient.Do(req) @@ -128,7 +131,7 @@ func (c Client) do(req *http.Request, result any) error { return nil } -func (c Client) createEndpoint(domain string, params url.Values) *url.URL { +func (c *Client) createEndpoint(domain string, params url.Values) *url.URL { endpoint := c.baseURL.JoinPath("domains", domain, "records") params.Set("SIGNATURE", c.signature) @@ -165,6 +168,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var apiErr APIError + err := json.Unmarshal(raw, &apiErr) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/epik/internal/client_test.go b/providers/dns/epik/internal/client_test.go index 78c4452f0..b7c6f97df 100644 --- a/providers/dns/epik/internal/client_test.go +++ b/providers/dns/epik/internal/client_test.go @@ -1,40 +1,38 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestClient_GetDNSRecords(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /domains/example.com/records", + servermock.ResponseFromFixture("getDnsRecord.json"), + servermock.CheckQueryParameter().Strict(). + With("SIGNATURE", "secret")). + Build(t) - mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodGet, http.StatusOK, "getDnsRecord.json")) - - records, err := client.GetDNSRecords(context.Background(), "example.com") + records, err := client.GetDNSRecords(t.Context(), "example.com") require.NoError(t, err) expected := []Record{ @@ -89,18 +87,25 @@ func TestClient_GetDNSRecords(t *testing.T) { } func TestClient_GetDNSRecords_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /domains/example.com/records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized), + servermock.CheckQueryParameter().Strict(). + With("SIGNATURE", "secret")). + Build(t) - mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json")) - - _, err := client.GetDNSRecords(context.Background(), "example.com") + _, err := client.GetDNSRecords(t.Context(), "example.com") require.Error(t, err) } func TestClient_CreateHostRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodPost, http.StatusOK, "createHostRecord.json")) + client := mockBuilder(). + Route("POST /domains/example.com/records", + servermock.ResponseFromFixture("createHostRecord.json"), + servermock.CheckQueryParameter().Strict(). + With("SIGNATURE", "secret")). + Build(t) record := RecordRequest{ Host: "www2", @@ -110,7 +115,7 @@ func TestClient_CreateHostRecord(t *testing.T) { TTL: 300, } - data, err := client.CreateHostRecord(context.Background(), "example.com", record) + data, err := client.CreateHostRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &Data{ @@ -122,9 +127,13 @@ func TestClient_CreateHostRecord(t *testing.T) { } func TestClient_CreateHostRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodPost, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("POST /domains/example.com/records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized), + servermock.CheckQueryParameter().Strict(). + With("SIGNATURE", "secret")). + Build(t) record := RecordRequest{ Host: "www2", @@ -134,16 +143,20 @@ func TestClient_CreateHostRecord_error(t *testing.T) { TTL: 300, } - _, err := client.CreateHostRecord(context.Background(), "example.com", record) + _, err := client.CreateHostRecord(t.Context(), "example.com", record) require.Error(t, err) } func TestClient_RemoveHostRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /domains/example.com/records", + servermock.ResponseFromFixture("removeHostRecord.json"), + servermock.CheckQueryParameter().Strict(). + With("ID", "abc123"). + With("SIGNATURE", "secret")). + Build(t) - mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodDelete, http.StatusOK, "removeHostRecord.json")) - - data, err := client.RemoveHostRecord(context.Background(), "example.com", "abc123") + data, err := client.RemoveHostRecord(t.Context(), "example.com", "abc123") require.NoError(t, err) expected := &Data{ @@ -155,45 +168,12 @@ func TestClient_RemoveHostRecord(t *testing.T) { } func TestClient_RemoveHostRecord_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /domains/example.com/records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodDelete, http.StatusUnauthorized, "error.json")) - - _, err := client.RemoveHostRecord(context.Background(), "example.com", "abc123") + _, err := client.RemoveHostRecord(t.Context(), "example.com", "abc123") require.Error(t, err) } - -func testHandler(method string, statusCode int, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.URL.Query().Get("SIGNATURE") - if auth != "secret" { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - - rw.WriteHeader(statusCode) - - if statusCode == http.StatusNoContent { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - } -} diff --git a/providers/dns/eurodns/eurodns.go b/providers/dns/eurodns/eurodns.go new file mode 100644 index 000000000..21ff3c3a9 --- /dev/null +++ b/providers/dns/eurodns/eurodns.go @@ -0,0 +1,197 @@ +// Package eurodns implements a DNS provider for solving the DNS-01 challenge using EuroDNS. +package eurodns + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/eurodns/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "EURODNS_" + + EnvApplicationID = envNamespace + "APP_ID" + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + ApplicationID string + APIKey string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, internal.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for EuroDNS. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvApplicationID, EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("eurodns: %w", err) + } + + config := NewDefaultConfig() + config.ApplicationID = values[EnvApplicationID] + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for EuroDNS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("eurodns: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.ApplicationID, config.APIKey) + if err != nil { + return nil, fmt.Errorf("eurodns: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("eurodns: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("eurodns: %w", err) + } + + authZone = dns01.UnFqdn(authZone) + + zone, err := d.client.GetZone(ctx, authZone) + if err != nil { + return fmt.Errorf("eurodns: get zone: %w", err) + } + + zone.Records = append(zone.Records, internal.Record{ + Type: "TXT", + Host: subDomain, + TTL: internal.TTLRounder(d.config.TTL), + RData: info.Value, + }) + + validation, err := d.client.ValidateZone(ctx, authZone, zone) + if err != nil { + return fmt.Errorf("eurodns: validate zone: %w", err) + } + + if validation.Report != nil && !validation.Report.IsValid { + return fmt.Errorf("eurodns: validation report: %w", validation.Report) + } + + err = d.client.SaveZone(ctx, authZone, zone) + if err != nil { + return fmt.Errorf("eurodns: save zone: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("eurodns: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("eurodns: %w", err) + } + + authZone = dns01.UnFqdn(authZone) + + zone, err := d.client.GetZone(ctx, authZone) + if err != nil { + return fmt.Errorf("eurodns: get zone: %w", err) + } + + var recordsToKeep []internal.Record + + for _, record := range zone.Records { + if record.Type == "TXT" && record.Host == subDomain && record.RData == info.Value { + continue + } + + recordsToKeep = append(recordsToKeep, record) + } + + zone.Records = recordsToKeep + + validation, err := d.client.ValidateZone(ctx, authZone, zone) + if err != nil { + return fmt.Errorf("eurodns: validate zone: %w", err) + } + + if validation.Report != nil && !validation.Report.IsValid { + return fmt.Errorf("eurodns: validation report: %w", validation.Report) + } + + err = d.client.SaveZone(ctx, authZone, zone) + if err != nil { + return fmt.Errorf("eurodns: save zone: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/eurodns/eurodns.toml b/providers/dns/eurodns/eurodns.toml new file mode 100644 index 000000000..302b15d00 --- /dev/null +++ b/providers/dns/eurodns/eurodns.toml @@ -0,0 +1,24 @@ +Name = "EuroDNS" +Description = '''''' +URL = "https://www.eurodns.com/" +Code = "eurodns" +Since = "v4.33.0" + +Example = ''' +EURODNS_APP_ID="xxx" \ +EURODNS_API_KEY="yyy" \ +lego --dns eurodns -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + EURODNS_APP_ID = "Application ID" + EURODNS_API_KEY = "API key" + [Configuration.Additional] + EURODNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + EURODNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + EURODNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" + EURODNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://docapi.eurodns.com/" diff --git a/providers/dns/eurodns/eurodns_test.go b/providers/dns/eurodns/eurodns_test.go new file mode 100644 index 000000000..abbb4717e --- /dev/null +++ b/providers/dns/eurodns/eurodns_test.go @@ -0,0 +1,215 @@ +package eurodns + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/go-acme/lego/v4/providers/dns/eurodns/internal" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvApplicationID, EnvAPIKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvApplicationID: "abc", + EnvAPIKey: "secret", + }, + }, + { + desc: "missing application ID", + envVars: map[string]string{ + EnvApplicationID: "", + EnvAPIKey: "secret", + }, + expected: "eurodns: some credentials information are missing: EURODNS_APP_ID", + }, + { + desc: "missing API secret", + envVars: map[string]string{ + EnvApplicationID: "", + EnvAPIKey: "secret", + }, + expected: "eurodns: some credentials information are missing: EURODNS_APP_ID", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "eurodns: some credentials information are missing: EURODNS_APP_ID,EURODNS_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + appID string + apiKey string + expected string + }{ + { + desc: "success", + appID: "abc", + apiKey: "secret", + }, + { + desc: "missing application ID", + expected: "eurodns: credentials missing", + apiKey: "secret", + }, + { + desc: "missing API secret", + expected: "eurodns: credentials missing", + appID: "abc", + }, + { + desc: "missing credentials", + expected: "eurodns: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.ApplicationID = test.appID + config.APIKey = test.apiKey + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIKey = "secret" + config.ApplicationID = "abc" + config.HTTPClient = server.Client() + + provider, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + provider.client.BaseURL, _ = url.Parse(server.URL) + + return provider, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With(internal.HeaderAppID, "abc"). + With(internal.HeaderAPIKey, "secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("GET /example.com", + servermock.ResponseFromInternal("zone_get.json"), + ). + Route("POST /example.com/check", + servermock.ResponseFromInternal("zone_add_validate_ok.json"), + servermock.CheckRequestJSONBodyFromInternal("zone_add.json"), + ). + Route("PUT /example.com", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + servermock.CheckRequestJSONBodyFromInternal("zone_add.json"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("GET /example.com", + servermock.ResponseFromInternal("zone_add.json"), + ). + Route("POST /example.com/check", + servermock.ResponseFromInternal("zone_remove.json"), + servermock.CheckRequestJSONBodyFromInternal("zone_remove.json"), + ). + Route("PUT /example.com", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + servermock.CheckRequestJSONBodyFromInternal("zone_remove.json"), + ). + Build(t) + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/eurodns/internal/client.go b/providers/dns/eurodns/internal/client.go new file mode 100644 index 000000000..1ebf8d143 --- /dev/null +++ b/providers/dns/eurodns/internal/client.go @@ -0,0 +1,199 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const defaultBaseURL = "https://rest-api.eurodns.com/dns-zones/" + +const ( + HeaderAppID = "X-APP-ID" + HeaderAPIKey = "X-API-KEY" +) + +// Client the EuroDNS API client. +type Client struct { + appID string + apiKey string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(appID, apiKey string) (*Client, error) { + if appID == "" || apiKey == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + appID: appID, + apiKey: apiKey, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// GetZone gets a DNS Zone. +// https://docapi.eurodns.com/#/dnsprovider/getdnszone +func (c *Client) GetZone(ctx context.Context, domain string) (*Zone, error) { + endpoint := c.BaseURL.JoinPath(domain) + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + result := &Zone{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// SaveZone saves a DNS Zone. +// https://docapi.eurodns.com/#/dnsprovider/savednszone +func (c *Client) SaveZone(ctx context.Context, domain string, zone *Zone) error { + endpoint := c.BaseURL.JoinPath(domain) + + if len(zone.URLForwards) == 0 { + zone.URLForwards = make([]URLForward, 0) + } + + if len(zone.MailForwards) == 0 { + zone.MailForwards = make([]MailForward, 0) + } + + req, err := newJSONRequest(ctx, http.MethodPut, endpoint, zone) + if err != nil { + return err + } + + return c.do(req, nil) +} + +// ValidateZone validates DNS Zone. +// https://docapi.eurodns.com/#/dnsprovider/checkdnszone +func (c *Client) ValidateZone(ctx context.Context, domain string, zone *Zone) (*Zone, error) { + endpoint := c.BaseURL.JoinPath(domain, "check") + + if len(zone.URLForwards) == 0 { + zone.URLForwards = make([]URLForward, 0) + } + + if len(zone.MailForwards) == 0 { + zone.MailForwards = make([]MailForward, 0) + } + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, zone) + if err != nil { + return nil, err + } + + result := &Zone{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +func (c *Client) do(req *http.Request, result any) error { + req.Header.Set(HeaderAppID, c.appID) + req.Header.Set(HeaderAPIKey, c.apiKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return fmt.Errorf("%d: %w", resp.StatusCode, &errAPI) +} + +const DefaultTTL = 600 + +// TTLRounder rounds the given TTL in seconds to the next accepted value. +// Accepted TTL values are: 600, 900, 1800,3600, 7200, 14400, 21600, 43200, 86400, 172800, 432000, 604800. +func TTLRounder(ttl int) int { + for _, validTTL := range []int{DefaultTTL, 900, 1800, 3600, 7200, 14400, 21600, 43200, 86400, 172800, 432000, 604800} { + if ttl <= validTTL { + return validTTL + } + } + + return DefaultTTL +} diff --git a/providers/dns/eurodns/internal/client_test.go b/providers/dns/eurodns/internal/client_test.go new file mode 100644 index 000000000..68d1fda84 --- /dev/null +++ b/providers/dns/eurodns/internal/client_test.go @@ -0,0 +1,310 @@ +package internal + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("abc", "secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + client.BaseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With(HeaderAppID, "abc"). + With(HeaderAPIKey, "secret"), + ) +} + +func TestClient_GetZone(t *testing.T) { + client := mockBuilder(). + Route("GET /example.com", + servermock.ResponseFromFixture("zone_get.json"), + ). + Build(t) + + zone, err := client.GetZone(context.Background(), "example.com") + require.NoError(t, err) + + expected := &Zone{ + Name: "example.com", + DomainConnect: true, + Records: slices.Concat([]Record{fakeARecord()}), + URLForwards: []URLForward{fakeURLForward()}, + MailForwards: []MailForward{fakeMailForward()}, + } + + assert.Equal(t, expected, zone) +} + +func TestClient_GetZone_error(t *testing.T) { + client := mockBuilder(). + Route("GET /example.com", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized), + ). + Build(t) + + _, err := client.GetZone(context.Background(), "example.com") + require.Error(t, err) + + require.EqualError(t, err, "401: INVALID_API_KEY: Invalid API Key") +} + +func TestClient_SaveZone(t *testing.T) { + client := mockBuilder(). + Route("PUT /example.com", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + servermock.CheckRequestJSONBodyFromFixture("zone_add.json"), + ). + Build(t) + + record := Record{ + Type: "TXT", + Host: "_acme-challenge", + RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 600, + } + + zone := &Zone{ + Name: "example.com", + DomainConnect: true, + Records: []Record{fakeARecord(), record}, + URLForwards: []URLForward{fakeURLForward()}, + MailForwards: []MailForward{fakeMailForward()}, + } + + err := client.SaveZone(context.Background(), "example.com", zone) + require.NoError(t, err) +} + +func TestClient_SaveZone_emptyForwards(t *testing.T) { + client := mockBuilder(). + Route("PUT /example.com", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + servermock.CheckRequestJSONBodyFromFixture("zone_add_empty_forwards.json"), + ). + Build(t) + + record := Record{ + Type: "TXT", + Host: "_acme-challenge", + RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 600, + } + + zone := &Zone{ + Name: "example.com", + DomainConnect: true, + Records: slices.Concat([]Record{fakeARecord(), record}), + } + + err := client.SaveZone(context.Background(), "example.com", zone) + require.NoError(t, err) +} + +func TestClient_SaveZone_error(t *testing.T) { + client := mockBuilder(). + Route("PUT /example.com", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized), + ). + Build(t) + + zone := &Zone{ + Name: "example.com", + DomainConnect: true, + Records: []Record{fakeARecord()}, + URLForwards: []URLForward{fakeURLForward()}, + MailForwards: []MailForward{fakeMailForward()}, + } + + err := client.SaveZone(context.Background(), "example.com", zone) + require.Error(t, err) + + require.EqualError(t, err, "401: INVALID_API_KEY: Invalid API Key") +} + +func TestClient_ValidateZone(t *testing.T) { + client := mockBuilder(). + Route("POST /example.com/check", + servermock.ResponseFromFixture("zone_add_validate_ok.json"), + servermock.CheckRequestJSONBodyFromFixture("zone_add.json"), + ). + Build(t) + + record := Record{ + Type: "TXT", + Host: "_acme-challenge", + RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 600, + } + + zone := &Zone{ + Name: "example.com", + DomainConnect: true, + Records: []Record{fakeARecord(), record}, + URLForwards: []URLForward{fakeURLForward()}, + MailForwards: []MailForward{fakeMailForward()}, + } + + zone, err := client.ValidateZone(context.Background(), "example.com", zone) + require.NoError(t, err) + + expected := &Zone{ + Name: "example.com", + DomainConnect: true, + Records: []Record{fakeARecord(), record}, + URLForwards: []URLForward{fakeURLForward()}, + MailForwards: []MailForward{fakeMailForward()}, + Report: &Report{IsValid: true}, + } + + assert.Equal(t, expected, zone) +} + +func TestClient_ValidateZone_report(t *testing.T) { + client := mockBuilder(). + Route("POST /example.com/check", + servermock.ResponseFromFixture("zone_add_validate_ko.json"), + servermock.CheckRequestJSONBodyFromFixture("zone_add.json"), + ). + Build(t) + + record := Record{ + Type: "TXT", + Host: "_acme-challenge", + RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 600, + } + + zone := &Zone{ + Name: "example.com", + DomainConnect: true, + Records: []Record{fakeARecord(), record}, + URLForwards: []URLForward{fakeURLForward()}, + MailForwards: []MailForward{fakeMailForward()}, + } + + zone, err := client.ValidateZone(context.Background(), "example.com", zone) + require.NoError(t, err) + + expected := &Zone{ + Name: "example.com", + DomainConnect: true, + Records: []Record{fakeARecord(), record}, + URLForwards: []URLForward{fakeURLForward()}, + MailForwards: []MailForward{fakeMailForward()}, + Report: fakeReport(), + } + + assert.EqualError(t, zone.Report, `record error (ERROR): "120" is not a valid TTL, URL forward error (ERROR): string, mail forward error (ERROR): string, zone error (ERROR): string`) + + assert.Equal(t, expected, zone) +} + +func TestClient_ValidateZone_error(t *testing.T) { + client := mockBuilder(). + Route("POST /example.com/check", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized), + ). + Build(t) + + zone := &Zone{ + Name: "example.com", + DomainConnect: true, + Records: []Record{fakeARecord()}, + URLForwards: []URLForward{fakeURLForward()}, + MailForwards: []MailForward{fakeMailForward()}, + } + + _, err := client.ValidateZone(context.Background(), "example.com", zone) + require.Error(t, err) + + require.EqualError(t, err, "401: INVALID_API_KEY: Invalid API Key") +} + +func fakeARecord() Record { + return Record{ + ID: 1000, + Type: "A", + Host: "@", + TTL: 600, + RData: "string", + Updated: ptr.Pointer(true), + Locked: ptr.Pointer(true), + IsDynDNS: ptr.Pointer(true), + Proxy: "ON", + } +} + +func fakeURLForward() URLForward { + return URLForward{ + ID: 2000, + ForwardType: "FRAME", + Host: "string", + URL: "string", + Title: "string", + Keywords: "string", + Description: "string", + Updated: ptr.Pointer(true), + } +} + +func fakeMailForward() MailForward { + return MailForward{ + ID: 3000, + Source: "string", + Destination: "string", + Updated: ptr.Pointer(true), + } +} + +func fakeReport() *Report { + return &Report{ + IsValid: false, + RecordErrors: []RecordError{{ + Messages: []string{`"120" is not a valid TTL`}, + Severity: "ERROR", + Record: fakeARecord(), + }}, + URLForwardErrors: []URLForwardError{{ + Messages: []string{"string"}, + Severity: "ERROR", + URLForward: fakeURLForward(), + }}, + MailForwardErrors: []MailForwardError{{ + Messages: []string{"string"}, + MailForward: fakeMailForward(), + Severity: "ERROR", + }}, + ZoneErrors: []ZoneError{{ + Message: "string", + Severity: "ERROR", + Records: []Record{fakeARecord()}, + URLForwards: []URLForward{fakeURLForward()}, + MailForwards: []MailForward{fakeMailForward()}, + }}, + } +} diff --git a/providers/dns/eurodns/internal/fixtures/error.json b/providers/dns/eurodns/internal/fixtures/error.json new file mode 100644 index 000000000..82a334598 --- /dev/null +++ b/providers/dns/eurodns/internal/fixtures/error.json @@ -0,0 +1,8 @@ +{ + "errors": [ + { + "code": "INVALID_API_KEY", + "title": "Invalid API Key" + } + ] +} diff --git a/providers/dns/eurodns/internal/fixtures/zone_add.json b/providers/dns/eurodns/internal/fixtures/zone_add.json new file mode 100644 index 000000000..db8142357 --- /dev/null +++ b/providers/dns/eurodns/internal/fixtures/zone_add.json @@ -0,0 +1,46 @@ +{ + "name": "example.com", + "domainConnect": true, + "records": [ + { + "id": 1000, + "type": "A", + "host": "@", + "ttl": 600, + "rdata": "string", + "updated": true, + "locked": true, + "isDynDns": true, + "proxy": "ON" + }, + { + "type": "TXT", + "host": "_acme-challenge", + "ttl": 600, + "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "updated": null, + "locked": null, + "isDynDns": null + } + ], + "urlForwards": [ + { + "id": 2000, + "forwardType": "FRAME", + "host": "string", + "url": "string", + "title": "string", + "keywords": "string", + "description": "string", + "updated": true + } + ], + "mailForwards": [ + { + "id": 3000, + "source": "string", + "destination": "string", + "updated": true + } + ] +} diff --git a/providers/dns/eurodns/internal/fixtures/zone_add_empty_forwards.json b/providers/dns/eurodns/internal/fixtures/zone_add_empty_forwards.json new file mode 100644 index 000000000..64f8530c9 --- /dev/null +++ b/providers/dns/eurodns/internal/fixtures/zone_add_empty_forwards.json @@ -0,0 +1,28 @@ +{ + "name": "example.com", + "domainConnect": true, + "records": [ + { + "id": 1000, + "type": "A", + "host": "@", + "ttl": 600, + "rdata": "string", + "updated": true, + "locked": true, + "isDynDns": true, + "proxy": "ON" + }, + { + "type": "TXT", + "host": "_acme-challenge", + "ttl": 600, + "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "updated": null, + "locked": null, + "isDynDns": null + } + ], + "urlForwards": [], + "mailForwards": [] +} diff --git a/providers/dns/eurodns/internal/fixtures/zone_add_validate_ko.json b/providers/dns/eurodns/internal/fixtures/zone_add_validate_ko.json new file mode 100644 index 000000000..e07d42299 --- /dev/null +++ b/providers/dns/eurodns/internal/fixtures/zone_add_validate_ko.json @@ -0,0 +1,139 @@ +{ + "name": "example.com", + "domainConnect": true, + "records": [ + { + "id": 1000, + "type": "A", + "host": "@", + "ttl": 600, + "rdata": "string", + "updated": true, + "locked": true, + "isDynDns": true, + "proxy": "ON" + }, + { + "type": "TXT", + "host": "_acme-challenge", + "ttl": 600, + "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "updated": null, + "locked": null, + "isDynDns": null + } + ], + "urlForwards": [ + { + "id": 2000, + "forwardType": "FRAME", + "host": "string", + "url": "string", + "title": "string", + "keywords": "string", + "description": "string", + "updated": true + } + ], + "mailForwards": [ + { + "id": 3000, + "source": "string", + "destination": "string", + "updated": true + } + ], + "report": { + "isValid": false, + "recordErrors": [ + { + "messages": [ + "\"120\" is not a valid TTL" + ], + "record": { + "id": 1000, + "type": "A", + "host": "@", + "ttl": 600, + "rdata": "string", + "updated": true, + "locked": true, + "isDynDns": true, + "proxy": "ON" + }, + "severity": "ERROR" + } + ], + "urlForwardErrors": [ + { + "messages": [ + "string" + ], + "urlForward": { + "id": 2000, + "forwardType": "FRAME", + "host": "string", + "url": "string", + "title": "string", + "keywords": "string", + "description": "string", + "updated": true + }, + "severity": "ERROR" + } + ], + "mailForwardErrors": [ + { + "messages": [ + "string" + ], + "mailForward": { + "id": 3000, + "source": "string", + "destination": "string", + "updated": true + }, + "severity": "ERROR" + } + ], + "zoneErrors": [ + { + "message": "string", + "records": [ + { + "id": 1000, + "type": "A", + "host": "@", + "ttl": 600, + "rdata": "string", + "updated": true, + "locked": true, + "isDynDns": true, + "proxy": "ON" + } + ], + "urlForwards": [ + { + "id": 2000, + "forwardType": "FRAME", + "host": "string", + "url": "string", + "title": "string", + "keywords": "string", + "description": "string", + "updated": true + } + ], + "mailForwards": [ + { + "id": 3000, + "source": "string", + "destination": "string", + "updated": true + } + ], + "severity": "ERROR" + } + ] + } +} diff --git a/providers/dns/eurodns/internal/fixtures/zone_add_validate_ok.json b/providers/dns/eurodns/internal/fixtures/zone_add_validate_ok.json new file mode 100644 index 000000000..ba0ddfefb --- /dev/null +++ b/providers/dns/eurodns/internal/fixtures/zone_add_validate_ok.json @@ -0,0 +1,49 @@ +{ + "name": "example.com", + "domainConnect": true, + "records": [ + { + "id": 1000, + "type": "A", + "host": "@", + "ttl": 600, + "rdata": "string", + "updated": true, + "locked": true, + "isDynDns": true, + "proxy": "ON" + }, + { + "type": "TXT", + "host": "_acme-challenge", + "ttl": 600, + "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "updated": null, + "locked": null, + "isDynDns": null + } + ], + "urlForwards": [ + { + "id": 2000, + "forwardType": "FRAME", + "host": "string", + "url": "string", + "title": "string", + "keywords": "string", + "description": "string", + "updated": true + } + ], + "mailForwards": [ + { + "id": 3000, + "source": "string", + "destination": "string", + "updated": true + } + ], + "report": { + "isValid": true + } +} diff --git a/providers/dns/eurodns/internal/fixtures/zone_get.json b/providers/dns/eurodns/internal/fixtures/zone_get.json new file mode 100644 index 000000000..ebbc8593e --- /dev/null +++ b/providers/dns/eurodns/internal/fixtures/zone_get.json @@ -0,0 +1,37 @@ +{ + "name": "example.com", + "domainConnect": true, + "records": [ + { + "id": 1000, + "type": "A", + "host": "@", + "ttl": 600, + "rdata": "string", + "updated": true, + "locked": true, + "isDynDns": true, + "proxy": "ON" + } + ], + "urlForwards": [ + { + "id": 2000, + "forwardType": "FRAME", + "host": "string", + "url": "string", + "title": "string", + "keywords": "string", + "description": "string", + "updated": true + } + ], + "mailForwards": [ + { + "id": 3000, + "source": "string", + "destination": "string", + "updated": true + } + ] +} diff --git a/providers/dns/eurodns/internal/fixtures/zone_remove.json b/providers/dns/eurodns/internal/fixtures/zone_remove.json new file mode 100644 index 000000000..ebbc8593e --- /dev/null +++ b/providers/dns/eurodns/internal/fixtures/zone_remove.json @@ -0,0 +1,37 @@ +{ + "name": "example.com", + "domainConnect": true, + "records": [ + { + "id": 1000, + "type": "A", + "host": "@", + "ttl": 600, + "rdata": "string", + "updated": true, + "locked": true, + "isDynDns": true, + "proxy": "ON" + } + ], + "urlForwards": [ + { + "id": 2000, + "forwardType": "FRAME", + "host": "string", + "url": "string", + "title": "string", + "keywords": "string", + "description": "string", + "updated": true + } + ], + "mailForwards": [ + { + "id": 3000, + "source": "string", + "destination": "string", + "updated": true + } + ] +} diff --git a/providers/dns/eurodns/internal/types.go b/providers/dns/eurodns/internal/types.go new file mode 100644 index 000000000..891b02e14 --- /dev/null +++ b/providers/dns/eurodns/internal/types.go @@ -0,0 +1,136 @@ +package internal + +import ( + "fmt" + "strings" +) + +type APIError struct { + Errors []Error `json:"errors"` +} + +func (a *APIError) Error() string { + var msg []string + + for _, e := range a.Errors { + msg = append(msg, fmt.Sprintf("%s: %s", e.Code, e.Title)) + } + + return strings.Join(msg, ", ") +} + +type Error struct { + Code string `json:"code"` + Title string `json:"title"` +} + +type Zone struct { + Name string `json:"name,omitempty"` + DomainConnect bool `json:"domainConnect,omitempty"` + Records []Record `json:"records"` + URLForwards []URLForward `json:"urlForwards"` + MailForwards []MailForward `json:"mailForwards"` + Report *Report `json:"report,omitempty"` +} + +type Record struct { + ID int `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Host string `json:"host,omitempty"` + TTL int `json:"ttl,omitempty"` + RData string `json:"rdata,omitempty"` + Updated *bool `json:"updated"` + Locked *bool `json:"locked"` + IsDynDNS *bool `json:"isDynDns"` + Proxy string `json:"proxy,omitempty"` +} + +type URLForward struct { + ID int `json:"id,omitempty"` + ForwardType string `json:"forwardType,omitempty"` + Host string `json:"host,omitempty"` + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + Keywords string `json:"keywords,omitempty"` + Description string `json:"description,omitempty"` + Updated *bool `json:"updated,omitempty"` +} + +type MailForward struct { + ID int `json:"id,omitempty"` + Source string `json:"source,omitempty"` + Destination string `json:"destination,omitempty"` + Updated *bool `json:"updated,omitempty"` +} + +type Report struct { + IsValid bool `json:"isValid,omitempty"` + RecordErrors []RecordError `json:"recordErrors,omitempty"` + URLForwardErrors []URLForwardError `json:"urlForwardErrors,omitempty"` + MailForwardErrors []MailForwardError `json:"mailForwardErrors,omitempty"` + ZoneErrors []ZoneError `json:"zoneErrors,omitempty"` +} + +func (r *Report) Error() string { + var msg []string + + for _, e := range r.RecordErrors { + msg = append(msg, e.Error()) + } + + for _, e := range r.URLForwardErrors { + msg = append(msg, e.Error()) + } + + for _, e := range r.MailForwardErrors { + msg = append(msg, e.Error()) + } + + for _, e := range r.ZoneErrors { + msg = append(msg, e.Error()) + } + + return strings.Join(msg, ", ") +} + +type RecordError struct { + Messages []string `json:"messages,omitempty"` + Record Record `json:"record"` + Severity string `json:"severity,omitempty"` +} + +func (e *RecordError) Error() string { + return fmt.Sprintf("record error (%s): %s", e.Severity, strings.Join(e.Messages, ", ")) +} + +type URLForwardError struct { + Messages []string `json:"messages,omitempty"` + URLForward URLForward `json:"urlForward"` + Severity string `json:"severity,omitempty"` +} + +func (e *URLForwardError) Error() string { + return fmt.Sprintf("URL forward error (%s): %s", e.Severity, strings.Join(e.Messages, ", ")) +} + +type MailForwardError struct { + Messages []string `json:"messages,omitempty"` + MailForward MailForward `json:"mailForward"` + Severity string `json:"severity,omitempty"` +} + +func (e *MailForwardError) Error() string { + return fmt.Sprintf("mail forward error (%s): %s", e.Severity, strings.Join(e.Messages, ", ")) +} + +type ZoneError struct { + Message string `json:"message,omitempty"` + Records []Record `json:"records,omitempty"` + URLForwards []URLForward `json:"urlForwards,omitempty"` + MailForwards []MailForward `json:"mailForwards,omitempty"` + Severity string `json:"severity,omitempty"` +} + +func (e *ZoneError) Error() string { + return fmt.Sprintf("zone error (%s): %s", e.Severity, e.Message) +} diff --git a/providers/dns/excedo/excedo.go b/providers/dns/excedo/excedo.go new file mode 100644 index 000000000..ae9128b94 --- /dev/null +++ b/providers/dns/excedo/excedo.go @@ -0,0 +1,176 @@ +// Package excedo implements a DNS provider for solving the DNS-01 challenge using Excedo. +package excedo + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/excedo/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "EXCEDO_" + + EnvAPIURL = envNamespace + "API_URL" + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIURL string + APIKey string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, 60), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + recordsMu sync.Mutex + records map[string]int64 +} + +// NewDNSProvider returns a DNSProvider instance configured for Excedo. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIURL, EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("excedo: %w", err) + } + + config := NewDefaultConfig() + config.APIURL = values[EnvAPIURL] + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Excedo. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("excedo: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.APIURL, config.APIKey) + if err != nil { + return nil, fmt.Errorf("excedo: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + records: make(map[string]int64), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("excedo: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("excedo: %w", err) + } + + record := internal.Record{ + DomainName: dns01.UnFqdn(authZone), + Name: subDomain, + Type: "TXT", + Content: info.Value, + TTL: strconv.Itoa(d.config.TTL), + } + + recordID, err := d.client.AddRecord(ctx, record) + if err != nil { + return fmt.Errorf("excedo: add record: %w", err) + } + + d.recordsMu.Lock() + d.records[token] = recordID + d.recordsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("excedo: could not find zone for domain %q: %w", domain, err) + } + + d.recordsMu.Lock() + recordID, ok := d.records[token] + d.recordsMu.Unlock() + + if !ok { + return fmt.Errorf("excedo: unknown record ID for '%s'", info.EffectiveFQDN) + } + + err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), strconv.FormatInt(recordID, 10)) + if err != nil { + return fmt.Errorf("excedo: delete record: %w", err) + } + + d.recordsMu.Lock() + delete(d.records, token) + d.recordsMu.Unlock() + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/excedo/excedo.toml b/providers/dns/excedo/excedo.toml new file mode 100644 index 000000000..9f9874c62 --- /dev/null +++ b/providers/dns/excedo/excedo.toml @@ -0,0 +1,24 @@ +Name = "Excedo" +Description = '''''' +URL = "https://excedo.se/" +Code = "excedo" +Since = "v4.33.0" + +Example = ''' +EXCEDO_API_KEY=your-api-key \ +EXCEDO_API_URL=your-base-url \ +lego --dns excedo -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + EXCEDO_API_KEY = "API key" + EXCEDO_API_URL = "API base URL" + [Configuration.Additional] + EXCEDO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + EXCEDO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" + EXCEDO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + EXCEDO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "none" diff --git a/providers/dns/excedo/excedo_test.go b/providers/dns/excedo/excedo_test.go new file mode 100644 index 000000000..f2350c035 --- /dev/null +++ b/providers/dns/excedo/excedo_test.go @@ -0,0 +1,210 @@ +package excedo + +import ( + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIURL, EnvAPIKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIURL: "https://example.com", + EnvAPIKey: "secret", + }, + }, + { + desc: "missing the API key", + envVars: map[string]string{ + EnvAPIURL: "https://example.com", + EnvAPIKey: "", + }, + expected: "excedo: some credentials information are missing: EXCEDO_API_KEY", + }, + { + desc: "missing the API URL", + envVars: map[string]string{ + EnvAPIURL: "", + EnvAPIKey: "secret", + }, + expected: "excedo: some credentials information are missing: EXCEDO_API_URL", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "excedo: some credentials information are missing: EXCEDO_API_URL,EXCEDO_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiURL string + apiKey string + expected string + }{ + { + desc: "success", + apiURL: "https://example.com", + apiKey: "secret", + }, + { + desc: "missing the API key", + apiURL: "https://example.com", + expected: "excedo: credentials missing", + }, + { + desc: "missing the API URL", + apiKey: "secret", + expected: "excedo: credentials missing", + }, + { + desc: "missing credentials", + expected: "excedo: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIURL = test.apiURL + config.APIKey = test.apiKey + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIURL = server.URL + config.APIKey = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + return p, nil + }, + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("GET /authenticate/login/", + servermock.ResponseFromInternal("login.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer secret"), + ). + Route("POST /dns/addrecord/", + servermock.ResponseFromInternal("addrecord.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer session-token"), + servermock.CheckForm().Strict(). + With("content", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"). + With("domainName", "example.com"). + With("name", "_acme-challenge"). + With("ttl", "60"). + With("type", "TXT"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("GET /authenticate/login/", + servermock.ResponseFromInternal("login.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer secret"), + ). + Route("POST /dns/deleterecord/", + servermock.ResponseFromInternal("deleterecord.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer session-token"), + ). + Build(t) + + provider.records["abc"] = 19695822 + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/excedo/internal/client.go b/providers/dns/excedo/internal/client.go new file mode 100644 index 000000000..a5d8be88b --- /dev/null +++ b/providers/dns/excedo/internal/client.go @@ -0,0 +1,205 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" + querystring "github.com/google/go-querystring/query" +) + +type responseChecker interface { + Check() error +} + +// Client the Excedo API client. +type Client struct { + apiKey string + + baseURL *url.URL + HTTPClient *http.Client + + token *ExpirableToken + muToken sync.Mutex +} + +// NewClient creates a new Client. +func NewClient(apiURL, apiKey string) (*Client, error) { + if apiURL == "" || apiKey == "" { + return nil, errors.New("credentials missing") + } + + baseURL, err := url.Parse(apiURL) + if err != nil { + return nil, err + } + + return &Client{ + apiKey: apiKey, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) AddRecord(ctx context.Context, record Record) (int64, error) { + payload, err := querystring.Values(record) + if err != nil { + return 0, err + } + + endpoint := c.baseURL.JoinPath("/dns/addrecord/") + + req, err := newFormRequest(ctx, http.MethodPost, endpoint, payload) + if err != nil { + return 0, err + } + + result := new(AddRecordResponse) + + err = c.doAuthenticated(ctx, req, result) + if err != nil { + return 0, err + } + + return result.RecordID, nil +} + +func (c *Client) DeleteRecord(ctx context.Context, zone, recordID string) error { + endpoint := c.baseURL.JoinPath("/dns/deleterecord/") + + data := map[string]string{ + "domainname": dns01.UnFqdn(zone), + "recordid": recordID, + } + + req, err := newMultipartRequest(ctx, http.MethodPost, endpoint, data) + if err != nil { + return err + } + + result := new(BaseResponse) + + err = c.doAuthenticated(ctx, req, result) + if err != nil { + return err + } + + return nil +} + +func (c *Client) GetRecords(ctx context.Context, zone string) (map[string]Zone, error) { + endpoint := c.baseURL.JoinPath("/dns/getrecords/") + + query := endpoint.Query() + query.Set("domainname", zone) + + endpoint.RawQuery = query.Encode() + + req, err := newFormRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + result := new(GetRecordsResponse) + + err = c.doAuthenticated(ctx, req, result) + if err != nil { + return nil, err + } + + return result.DNS, nil +} + +func (c *Client) do(req *http.Request, result responseChecker) error { + useragent.SetHeader(req.Header) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + raw, _ := io.ReadAll(resp.Body) + + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return result.Check() +} + +func newMultipartRequest(ctx context.Context, method string, endpoint *url.URL, data map[string]string) (*http.Request, error) { + buf := new(bytes.Buffer) + + writer := multipart.NewWriter(buf) + + for k, v := range data { + err := writer.WriteField(k, v) + if err != nil { + return nil, err + } + } + + err := writer.Close() + if err != nil { + return nil, err + } + + body := bytes.NewReader(buf.Bytes()) + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + + return req, nil +} + +func newFormRequest(ctx context.Context, method string, endpoint *url.URL, form url.Values) (*http.Request, error) { + var body io.Reader + + if len(form) > 0 { + body = bytes.NewReader([]byte(form.Encode())) + } else { + body = http.NoBody + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + if method == http.MethodPost { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + + return req, nil +} diff --git a/providers/dns/excedo/internal/client_test.go b/providers/dns/excedo/internal/client_test.go new file mode 100644 index 000000000..f4fd52c00 --- /dev/null +++ b/providers/dns/excedo/internal/client_test.go @@ -0,0 +1,137 @@ +package internal + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + + return client, nil + }, + ) +} + +func TestClient_AddRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/addrecord/", + servermock.ResponseFromFixture("addrecord.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer session-token"), + servermock.CheckForm().Strict(). + With("content", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"). + With("domainName", "example.com"). + With("name", "_acme-challenge"). + With("ttl", "60"). + With("type", "TXT"), + ). + Build(t) + + client.token = &ExpirableToken{ + Token: "session-token", + Expires: time.Now().Add(6 * time.Hour), + } + + record := Record{ + DomainName: "example.com", + Name: "_acme-challenge", + Type: "TXT", + Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: "60", + } + + recordID, err := client.AddRecord(t.Context(), record) + require.NoError(t, err) + + assert.EqualValues(t, 19695822, recordID) +} + +func TestClient_AddRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/addrecord/", + servermock.ResponseFromFixture("error.json"), + ). + Build(t) + + client.token = &ExpirableToken{ + Token: "session-token", + Expires: time.Now().Add(6 * time.Hour), + } + + record := Record{ + DomainName: "example.com", + Name: "_acme-challenge", + Type: "TXT", + Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: "60", + } + + _, err := client.AddRecord(t.Context(), record) + require.EqualError(t, err, "2003: Required parameter missing") +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/deleterecord/", + servermock.ResponseFromFixture("deleterecord.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer session-token"), + ). + Build(t) + + client.token = &ExpirableToken{ + Token: "session-token", + Expires: time.Now().Add(6 * time.Hour), + } + + err := client.DeleteRecord(t.Context(), "example.com", "19695822") + require.NoError(t, err) +} + +func TestClient_GetRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/getrecords/", + servermock.ResponseFromFixture("getrecords.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer session-token"), + servermock.CheckQueryParameter().Strict(). + With("domainname", "example.com"), + ). + Build(t) + + client.token = &ExpirableToken{ + Token: "session-token", + Expires: time.Now().Add(6 * time.Hour), + } + + zones, err := client.GetRecords(t.Context(), "example.com") + require.NoError(t, err) + + expected := map[string]Zone{ + "example.com": { + DNSType: "type", + Records: []Record{{ + RecordID: "1234", + Name: "_acme-challenge.example.com", + Type: "TXT", + Content: "txt-value", + TTL: "60", + }}, + }, + } + + assert.Equal(t, expected, zones) +} diff --git a/providers/dns/excedo/internal/fixtures/addrecord.json b/providers/dns/excedo/internal/fixtures/addrecord.json new file mode 100644 index 000000000..f1f7bf958 --- /dev/null +++ b/providers/dns/excedo/internal/fixtures/addrecord.json @@ -0,0 +1,15 @@ +{ + "code": 1000, + "desc": "Command completed successfully", + "recordid": 19695822, + "session": { + "accID": "1234", + "usrID": "1234", + "status": "active", + "expire": { + "date": "2026-03-10 19:03:18", + "seconds": 5678 + } + }, + "runtime": 0.2852 +} diff --git a/providers/dns/excedo/internal/fixtures/deleterecord.json b/providers/dns/excedo/internal/fixtures/deleterecord.json new file mode 100644 index 000000000..5c2431b1c --- /dev/null +++ b/providers/dns/excedo/internal/fixtures/deleterecord.json @@ -0,0 +1,14 @@ +{ + "code": 1000, + "desc": "Command completed successfully", + "session": { + "accID": "1234", + "usrID": "1234", + "status": "active", + "expire": { + "date": "2026-03-10 19:03:18", + "seconds": 5678 + } + }, + "runtime": 0.2852 +} diff --git a/providers/dns/excedo/internal/fixtures/error.json b/providers/dns/excedo/internal/fixtures/error.json new file mode 100644 index 000000000..5a24ec247 --- /dev/null +++ b/providers/dns/excedo/internal/fixtures/error.json @@ -0,0 +1,18 @@ +{ + "code": 2003, + "desc": "Required parameter missing", + "missing": [ + "domainname", + "recordid" + ], + "session": { + "accID": "1234", + "usrID": "1234", + "status": "active", + "expire": { + "date": "2026-03-10 19:03:18", + "seconds": 5485 + } + }, + "runtime": 0.0534 +} diff --git a/providers/dns/excedo/internal/fixtures/getrecords.json b/providers/dns/excedo/internal/fixtures/getrecords.json new file mode 100644 index 000000000..215a8abb2 --- /dev/null +++ b/providers/dns/excedo/internal/fixtures/getrecords.json @@ -0,0 +1,23 @@ +{ + "code": 1000, + "desc": "Command completed successfully", + "dns": { + "example.com": { + "dnstype": "type", + "recordusage": { + "used": 74 + }, + "records": [ + { + "recordid": "1234", + "name": "_acme-challenge.example.com", + "type": "TXT", + "content": "txt-value", + "ttl": "60", + "prio": null, + "change_date": null + } + ] + } + } +} diff --git a/providers/dns/excedo/internal/fixtures/login.json b/providers/dns/excedo/internal/fixtures/login.json new file mode 100644 index 000000000..2defb9843 --- /dev/null +++ b/providers/dns/excedo/internal/fixtures/login.json @@ -0,0 +1,7 @@ +{ + "code": 1000, + "desc": "Command completed successfully", + "parameters": { + "token": "session-token" + } +} diff --git a/providers/dns/excedo/internal/identity.go b/providers/dns/excedo/internal/identity.go new file mode 100644 index 000000000..5c9ca119d --- /dev/null +++ b/providers/dns/excedo/internal/identity.go @@ -0,0 +1,75 @@ +package internal + +import ( + "context" + "fmt" + "net/http" + "time" +) + +type ExpirableToken struct { + Token string + Expires time.Time +} + +func (t *ExpirableToken) IsExpired() bool { + return time.Now().After(t.Expires) +} + +func (c *Client) Login(ctx context.Context) (string, error) { + endpoint := c.baseURL.JoinPath("/authenticate/login/") + + req, err := newFormRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + result := new(LoginResponse) + + err = c.do(req, result) + if err != nil { + return "", err + } + + if result.Code != 1000 && result.Code != 1300 { + return "", fmt.Errorf("%d: %s", result.Code, result.Description) + } + + return result.Parameters.Token, nil +} + +func (c *Client) authenticate(ctx context.Context) (string, error) { + c.muToken.Lock() + defer c.muToken.Unlock() + + if c.token == nil || c.token.IsExpired() { + token, err := c.Login(ctx) + if err != nil { + return "", err + } + + c.token = &ExpirableToken{ + Token: token, + Expires: time.Now().Add(2*time.Hour - time.Minute), + } + + return token, nil + } + + return c.token.Token, nil +} + +func (c *Client) doAuthenticated(ctx context.Context, req *http.Request, result responseChecker) error { + token, err := c.authenticate(ctx) + if err != nil { + return err + } + + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + return c.do(req, result) +} diff --git a/providers/dns/excedo/internal/identity_test.go b/providers/dns/excedo/internal/identity_test.go new file mode 100644 index 000000000..86b7eb9d8 --- /dev/null +++ b/providers/dns/excedo/internal/identity_test.go @@ -0,0 +1,35 @@ +package internal + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_Login(t *testing.T) { + client := mockBuilder(). + Route("GET /authenticate/login/", + servermock.ResponseFromFixture("login.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer secret"), + ). + Build(t) + + token, err := client.Login(t.Context()) + require.NoError(t, err) + + assert.Equal(t, "session-token", token) +} + +func TestClient_Login_error(t *testing.T) { + client := mockBuilder(). + Route("GET /authenticate/login/", + servermock.ResponseFromFixture("error.json"), + ). + Build(t) + + _, err := client.Login(t.Context()) + require.EqualError(t, err, "2003: Required parameter missing") +} diff --git a/providers/dns/excedo/internal/types.go b/providers/dns/excedo/internal/types.go new file mode 100644 index 000000000..eb6ce8462 --- /dev/null +++ b/providers/dns/excedo/internal/types.go @@ -0,0 +1,65 @@ +package internal + +import "fmt" + +type BaseResponse struct { + Code int `json:"code"` + Description string `json:"desc"` +} + +func (r BaseResponse) Check() error { + // Response codes: + // - 1000: Command completed successfully + // - 1300: Command completed successfully; no messages + // - 2001: Command syntax error + // - 2002: Command use error + // - 2003: Required parameter missing + // - 2004: Parameter value range error + // - 2104: Billing failure + // - 2200: Authentication error + // - 2201: Authorization error + // - 2303: Object does not exist + // - 2304: Object status prohibits operation + // - 2309: Object duplicate found + // - 2400: Command failed + // - 2500: Command failed; server closing connection + if r.Code != 1000 && r.Code != 1300 { + return fmt.Errorf("%d: %s", r.Code, r.Description) + } + + return nil +} + +type GetRecordsResponse struct { + BaseResponse + + DNS map[string]Zone `json:"dns"` +} + +type Zone struct { + DNSType string `json:"dnstype"` + Records []Record `json:"records"` +} + +type Record struct { + DomainName string `json:"domainName,omitempty" url:"domainName,omitempty"` + RecordID string `json:"recordid,omitempty" url:"recordid,omitempty"` + Name string `json:"name,omitempty" url:"name,omitempty"` + Type string `json:"type,omitempty" url:"type,omitempty"` + Content string `json:"content,omitempty" url:"content,omitempty"` + TTL string `json:"ttl,omitempty" url:"ttl,omitempty"` +} + +type AddRecordResponse struct { + BaseResponse + + RecordID int64 `json:"recordid"` +} + +type LoginResponse struct { + BaseResponse + + Parameters struct { + Token string `json:"token"` + } `json:"parameters"` +} diff --git a/providers/dns/exec/exec.go b/providers/dns/exec/exec.go index 23fdaf384..9f000b80d 100644 --- a/providers/dns/exec/exec.go +++ b/providers/dns/exec/exec.go @@ -10,6 +10,7 @@ import ( "os/exec" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" @@ -27,6 +28,8 @@ const ( EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config Provider configuration. type Config struct { Program string diff --git a/providers/dns/exec/exec.toml b/providers/dns/exec/exec.toml index b5a68e36a..2f9c77c67 100644 --- a/providers/dns/exec/exec.toml +++ b/providers/dns/exec/exec.toml @@ -6,7 +6,7 @@ Since = "v0.5.0" Example = ''' EXEC_PATH=/the/path/to/myscript.sh \ -lego --email you@example.com --dns exec -d '*.example.com' -d example.com run +lego --dns exec -d '*.example.com' -d example.com run ''' Additional = ''' @@ -21,11 +21,11 @@ Additional = ''' ## Additional Configuration -| Environment Variable Name | Description | -|----------------------------|-------------------------------------------| -| `EXEC_POLLING_INTERVAL` | Time between DNS propagation check. | -| `EXEC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation. | -| `EXEC_SEQUENCE_INTERVAL` | Time between sequential requests. | +| Environment Variable Name | Description | +|----------------------------|--------------------------------------------------------------------| +| `EXEC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 3). | +| `EXEC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60). | +| `EXEC_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60). | ## Description @@ -39,7 +39,7 @@ For example, requesting a certificate for the domain 'my.example.org' can be ach ```bash EXEC_PATH=./update-dns.sh \ -lego --email you@example.com --dns exec --d my.example.org run +lego --dns exec --d my.example.org run ``` It will then call the program './update-dns.sh' with like this: @@ -59,7 +59,7 @@ If you want to use the raw domain, token, and keyAuth values with your program, ```bash EXEC_MODE=RAW \ EXEC_PATH=./update-dns.sh \ -lego --email you@example.com --dns exec -d my.example.org run +lego --dns exec -d my.example.org run ``` It will then call the program `./update-dns.sh` like this: diff --git a/providers/dns/exec/exec_test.go b/providers/dns/exec/exec_test.go index 3a2edbbf4..c1b6da55e 100644 --- a/providers/dns/exec/exec_test.go +++ b/providers/dns/exec/exec_test.go @@ -14,6 +14,7 @@ import ( func TestDNSProvider_Present(t *testing.T) { backupLogger := log.Logger + defer func() { log.Logger = backupLogger }() @@ -62,6 +63,7 @@ func TestDNSProvider_Present(t *testing.T) { } var message string + logRecorder.On("Println", mock.Anything).Run(func(args mock.Arguments) { message = args.String(0) fmt.Fprintln(os.Stdout, "XXX", message) @@ -87,6 +89,7 @@ func TestDNSProvider_Present(t *testing.T) { func TestDNSProvider_CleanUp(t *testing.T) { backupLogger := log.Logger + defer func() { log.Logger = backupLogger }() @@ -135,6 +138,7 @@ func TestDNSProvider_CleanUp(t *testing.T) { } var message string + logRecorder.On("Println", mock.Anything).Run(func(args mock.Arguments) { message = args.String(0) fmt.Fprintln(os.Stdout, "XXX", message) diff --git a/providers/dns/exec/log_mock_test.go b/providers/dns/exec/log_mock_test.go index 47935cc55..65753dcf8 100644 --- a/providers/dns/exec/log_mock_test.go +++ b/providers/dns/exec/log_mock_test.go @@ -6,26 +6,26 @@ type LogRecorder struct { mock.Mock } -func (*LogRecorder) Fatal(args ...interface{}) { +func (*LogRecorder) Fatal(args ...any) { panic("implement me") } -func (*LogRecorder) Fatalln(args ...interface{}) { +func (*LogRecorder) Fatalln(args ...any) { panic("implement me") } -func (*LogRecorder) Fatalf(format string, args ...interface{}) { +func (*LogRecorder) Fatalf(format string, args ...any) { panic("implement me") } -func (*LogRecorder) Print(args ...interface{}) { +func (*LogRecorder) Print(args ...any) { panic("implement me") } -func (l *LogRecorder) Println(args ...interface{}) { +func (l *LogRecorder) Println(args ...any) { l.Called(args...) } -func (*LogRecorder) Printf(format string, args ...interface{}) { +func (*LogRecorder) Printf(format string, args ...any) { panic("implement me") } diff --git a/providers/dns/exoscale/exoscale.go b/providers/dns/exoscale/exoscale.go index c9402a75c..05fcb6a6f 100644 --- a/providers/dns/exoscale/exoscale.go +++ b/providers/dns/exoscale/exoscale.go @@ -6,12 +6,15 @@ import ( "errors" "fmt" "net/http" + "strconv" "time" egoscale "github.com/exoscale/egoscale/v3" "github.com/exoscale/egoscale/v3/credentials" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) @@ -29,6 +32,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -85,7 +90,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client, err := egoscale.NewClient( credentials.NewStaticCredentials(config.APIKey, config.APISecret), egoscale.ClientOptWithEndpoint(egoscale.Endpoint(config.Endpoint)), - egoscale.ClientOptWithHTTPClient(&http.Client{Timeout: config.HTTPTimeout}), + egoscale.ClientOptWithHTTPClient(clientdebug.Wrap(&http.Client{Timeout: config.HTTPTimeout})), egoscale.ClientOptWithUserAgent(useragent.Get()), ) if err != nil { @@ -101,6 +106,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) zoneName, recordName, err := d.findZoneAndRecordName(info.EffectiveFQDN) @@ -108,10 +114,11 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("exoscale: %w", err) } - zone, err := d.findExistingZone(zoneName) + zone, err := d.findExistingZone(ctx, zoneName) if err != nil { return fmt.Errorf("exoscale: %w", err) } + if zone == nil { return fmt.Errorf("exoscale: zone %q not found", zoneName) } @@ -139,6 +146,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // CleanUp removes the record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) zoneName, recordName, err := d.findZoneAndRecordName(info.EffectiveFQDN) @@ -146,15 +154,16 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("exoscale: %w", err) } - zone, err := d.findExistingZone(zoneName) + zone, err := d.findExistingZone(ctx, zoneName) if err != nil { return fmt.Errorf("exoscale: %w", err) } + if zone == nil { return fmt.Errorf("exoscale: zone %q not found", zoneName) } - recordID, err := d.findExistingRecordID(zone.ID, recordName, info.Value) + recordID, err := d.findExistingRecordID(ctx, zone.ID, recordName, info.Value) if err != nil { return err } @@ -184,9 +193,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { // findExistingZone Query Exoscale to find an existing zone for this name. // Returns nil result if no zone could be found. -func (d *DNSProvider) findExistingZone(zoneName string) (*egoscale.DNSDomain, error) { - ctx := context.Background() - +func (d *DNSProvider) findExistingZone(ctx context.Context, zoneName string) (*egoscale.DNSDomain, error) { zones, err := d.client.ListDNSDomains(ctx) if err != nil { return nil, fmt.Errorf("error while retrieving DNS zones: %w", err) @@ -203,16 +210,15 @@ func (d *DNSProvider) findExistingZone(zoneName string) (*egoscale.DNSDomain, er // findExistingRecordID Query Exoscale to find an existing record for this name. // Returns empty result if no record could be found. -func (d *DNSProvider) findExistingRecordID(zoneID egoscale.UUID, recordName string, value string) (egoscale.UUID, error) { - ctx := context.Background() - +func (d *DNSProvider) findExistingRecordID(ctx context.Context, zoneID egoscale.UUID, recordName, value string) (egoscale.UUID, error) { records, err := d.client.ListDNSDomainRecords(ctx, zoneID) if err != nil { return "", fmt.Errorf("error while retrieving DNS records: %w", err) } for _, record := range records.DNSDomainRecords { - if record.Name == recordName && record.Type == egoscale.DNSDomainRecordTypeTXT && record.Content == value { + if record.Name == recordName && record.Type == egoscale.DNSDomainRecordTypeTXT && + (record.Content == value || record.Content == strconv.Quote(value)) { return record.ID, nil } } diff --git a/providers/dns/exoscale/exoscale.toml b/providers/dns/exoscale/exoscale.toml index 28a756413..bcc912b07 100644 --- a/providers/dns/exoscale/exoscale.toml +++ b/providers/dns/exoscale/exoscale.toml @@ -7,7 +7,7 @@ Since = "v0.4.0" Example = ''' EXOSCALE_API_KEY=abcdefghijklmnopqrstuvwx \ EXOSCALE_API_SECRET=xxxxxxx \ -lego --email you@example.com --dns exoscale -d '*.example.com' -d example.com run +lego --dns exoscale -d '*.example.com' -d example.com run ''' [Configuration] @@ -16,10 +16,10 @@ lego --email you@example.com --dns exoscale -d '*.example.com' -d example.com ru EXOSCALE_API_SECRET = "API secret" [Configuration.Additional] EXOSCALE_ENDPOINT = "API endpoint URL" - EXOSCALE_POLLING_INTERVAL = "Time between DNS propagation check" - EXOSCALE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - EXOSCALE_TTL = "The TTL of the TXT record used for the DNS challenge" - EXOSCALE_HTTP_TIMEOUT = "API request timeout" + EXOSCALE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + EXOSCALE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + EXOSCALE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + EXOSCALE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" [Links] API = "https://openapi-v2.exoscale.com/#endpoint-dns" diff --git a/providers/dns/exoscale/exoscale_test.go b/providers/dns/exoscale/exoscale_test.go index fa58216a5..e9f6be602 100644 --- a/providers/dns/exoscale/exoscale_test.go +++ b/providers/dns/exoscale/exoscale_test.go @@ -58,6 +58,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -178,6 +179,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -195,6 +197,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/f5xc/f5xc.go b/providers/dns/f5xc/f5xc.go new file mode 100644 index 000000000..76a6e0262 --- /dev/null +++ b/providers/dns/f5xc/f5xc.go @@ -0,0 +1,201 @@ +// Package f5xc implements a DNS provider for solving the DNS-01 challenge using F5 XC. +package f5xc + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/cenkalti/backoff/v5" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/platform/wait" + "github.com/go-acme/lego/v4/providers/dns/f5xc/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "F5XC_" + + EnvToken = envNamespace + "API_TOKEN" + EnvTenantName = envNamespace + "TENANT_NAME" + EnvServer = envNamespace + "SERVER" + EnvGroupName = envNamespace + "GROUP_NAME" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIToken string + TenantName string + Server string + GroupName string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for F5 XC. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvToken, EnvTenantName, EnvGroupName) + if err != nil { + return nil, fmt.Errorf("f5xc: %w", err) + } + + config := NewDefaultConfig() + config.APIToken = values[EnvToken] + config.TenantName = values[EnvTenantName] + config.GroupName = values[EnvGroupName] + config.Server = env.GetOrFile(EnvServer) + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for F5 XC. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("f5xc: the configuration of the DNS provider is nil") + } + + if config.GroupName == "" { + return nil, errors.New("f5xc: missing group name") + } + + client, err := internal.NewClient(config.APIToken, config.TenantName, config.Server) + if err != nil { + return nil, fmt.Errorf("f5xc: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("f5xc: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("f5xc: %w", err) + } + + existingRRSet, err := d.client.GetRRSet(ctx, dns01.UnFqdn(authZone), d.config.GroupName, subDomain, "TXT") + if err != nil { + return fmt.Errorf("f5xc: get RR Set: %w", err) + } + + // New RRSet. + if existingRRSet == nil || existingRRSet.RRSet.TXTRecord == nil { + rrSet := internal.RRSet{ + Description: "lego", + TTL: d.config.TTL, + TXTRecord: &internal.TXTRecord{ + Name: subDomain, + Values: []string{info.Value}, + }, + } + + return d.waitFor(ctx, func() error { + _, err = d.client.CreateRRSet(ctx, dns01.UnFqdn(authZone), d.config.GroupName, rrSet) + if err != nil { + return fmt.Errorf("create RR set: %w", err) + } + + return nil + }) + } + + // Update RRSet. + existingRRSet.RRSet.TXTRecord.Values = append(existingRRSet.RRSet.TXTRecord.Values, info.Value) + + return d.waitFor(ctx, func() error { + _, err = d.client.ReplaceRRSet(ctx, dns01.UnFqdn(authZone), d.config.GroupName, subDomain, "TXT", existingRRSet.RRSet) + if err != nil { + return fmt.Errorf("replace RR set: %w", err) + } + + return nil + }) +} + +func (d *DNSProvider) waitFor(ctx context.Context, operation func() error) error { + err := wait.Retry(ctx, operation, + backoff.WithBackOff(backoff.NewConstantBackOff(2*time.Second)), + backoff.WithMaxElapsedTime(60*time.Second), + ) + if err != nil { + return fmt.Errorf("f5xc: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("f5xc: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("f5xc: %w", err) + } + + _, err = d.client.DeleteRRSet(context.Background(), dns01.UnFqdn(authZone), d.config.GroupName, subDomain, "TXT") + if err != nil { + return fmt.Errorf("f5xc: delete RR set: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/f5xc/f5xc.toml b/providers/dns/f5xc/f5xc.toml new file mode 100644 index 000000000..6be604ddd --- /dev/null +++ b/providers/dns/f5xc/f5xc.toml @@ -0,0 +1,28 @@ +Name = "F5 XC" +Description = '''''' +URL = "https://www.f5.com/products/distributed-cloud-services" +Code = "f5xc" +Since = "v4.23.0" + +Example = ''' +F5XC_API_TOKEN="xxx" \ +F5XC_TENANT_NAME="yyy" \ +F5XC_GROUP_NAME="zzz" \ +lego --dns f5xc -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + F5XC_API_TOKEN = "API token" + F5XC_TENANT_NAME = "XC Tenant shortname" + F5XC_GROUP_NAME = "Group name" + [Configuration.Additional] + F5XC_SERVER = "Server domain (Default: console.ves.volterra.io)" + F5XC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + F5XC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + F5XC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + F5XC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset" + Documentation = "https://my.f5.com/manage/s/article/K000147937" diff --git a/providers/dns/f5xc/f5xc_test.go b/providers/dns/f5xc/f5xc_test.go new file mode 100644 index 000000000..890a4cf09 --- /dev/null +++ b/providers/dns/f5xc/f5xc_test.go @@ -0,0 +1,174 @@ +package f5xc + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvToken, + EnvTenantName, + EnvServer, + EnvGroupName, +).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvToken: "secret", + EnvTenantName: "shortname", + EnvGroupName: "group", + }, + }, + { + desc: "missing API token", + envVars: map[string]string{ + EnvToken: "", + EnvTenantName: "shortname", + EnvGroupName: "group", + }, + expected: "f5xc: some credentials information are missing: F5XC_API_TOKEN", + }, + { + desc: "missing tenant name", + envVars: map[string]string{ + EnvToken: "secret", + EnvTenantName: "", + EnvGroupName: "group", + }, + expected: "f5xc: some credentials information are missing: F5XC_TENANT_NAME", + }, + { + desc: "missing group name", + envVars: map[string]string{ + EnvToken: "secret", + EnvTenantName: "shortname", + EnvGroupName: "", + }, + expected: "f5xc: some credentials information are missing: F5XC_GROUP_NAME", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "f5xc: some credentials information are missing: F5XC_API_TOKEN,F5XC_TENANT_NAME,F5XC_GROUP_NAME", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiToken string + tenantName string + groupName string + expected string + }{ + { + desc: "success", + apiToken: "secret", + tenantName: "shortname", + groupName: "group", + }, + { + desc: "missing API token", + tenantName: "shortname", + groupName: "group", + expected: "f5xc: credentials missing", + }, + { + desc: "missing tenant name", + apiToken: "secret", + groupName: "group", + expected: "f5xc: missing tenant name", + }, + { + desc: "missing group name", + apiToken: "secret", + tenantName: "shortname", + expected: "f5xc: missing group name", + }, + { + desc: "missing credentials", + expected: "f5xc: missing group name", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIToken = test.apiToken + config.TenantName = test.tenantName + config.GroupName = test.groupName + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/f5xc/internal/client.go b/providers/dns/f5xc/internal/client.go new file mode 100644 index 000000000..7beab0d03 --- /dev/null +++ b/providers/dns/f5xc/internal/client.go @@ -0,0 +1,224 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const defaultServer = "console.ves.volterra.io" + +const authorizationHeader = "Authorization" + +// Client the F5 XC API client. +type Client struct { + apiToken string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(apiToken, tenantName, server string) (*Client, error) { + if apiToken == "" { + return nil, errors.New("credentials missing") + } + + baseURL, err := createBaseURL(tenantName, server) + if err != nil { + return nil, err + } + + return &Client{ + apiToken: apiToken, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// CreateRRSet creates RRSet. +// https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset#operation/ves.io.schema.dns_zone.rrset.CustomAPI.Create +func (c *Client) CreateRRSet(ctx context.Context, dnsZoneName, groupName string, rrSet RRSet) (*APIRRSet, error) { + endpoint := c.baseURL.JoinPath("api", "config", "dns", "namespaces", "system", "dns_zones", dnsZoneName, "rrsets", groupName) + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, APIRRSet{ + DNSZoneName: dnsZoneName, + GroupName: groupName, + RRSet: rrSet, + }) + if err != nil { + return nil, err + } + + result := &APIRRSet{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// GetRRSet gets RRSets. +// https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset#operation/ves.io.schema.dns_zone.rrset.CustomAPI.Get +func (c *Client) GetRRSet(ctx context.Context, dnsZoneName, groupName, recordName, recordType string) (*APIRRSet, error) { + endpoint := c.baseURL.JoinPath("api", "config", "dns", "namespaces", "system", "dns_zones", dnsZoneName, "rrsets", groupName, recordName, recordType) + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + result := &APIRRSet{} + + err = c.do(req, result) + if err != nil { + usce := &APIError{} + if errors.As(err, &usce) && usce.StatusCode == http.StatusNotFound { + return nil, nil + } + + return nil, err + } + + return result, nil +} + +// DeleteRRSet deletes RRSet. +// https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset#operation/ves.io.schema.dns_zone.rrset.CustomAPI.Delete +func (c *Client) DeleteRRSet(ctx context.Context, dnsZoneName, groupName, recordName, recordType string) (*APIRRSet, error) { + endpoint := c.baseURL.JoinPath("api", "config", "dns", "namespaces", "system", "dns_zones", dnsZoneName, "rrsets", groupName, recordName, recordType) + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return nil, err + } + + result := &APIRRSet{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// ReplaceRRSet replaces RRSet. +// https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset#operation/ves.io.schema.dns_zone.rrset.CustomAPI.Replace +func (c *Client) ReplaceRRSet(ctx context.Context, dnsZoneName, groupName, recordName, recordType string, rrSet RRSet) (*APIRRSet, error) { + endpoint := c.baseURL.JoinPath("api", "config", "dns", "namespaces", "system", "dns_zones", dnsZoneName, "rrsets", groupName, recordName, recordType) + + req, err := newJSONRequest(ctx, http.MethodPut, endpoint, APIRRSet{ + DNSZoneName: dnsZoneName, + GroupName: groupName, + RRSet: rrSet, + Type: recordType, + }) + if err != nil { + return nil, err + } + + result := &APIRRSet{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +func (c *Client) do(req *http.Request, result any) error { + req.Header.Set(authorizationHeader, "APIToken "+c.apiToken) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + apiErr := APIError{StatusCode: resp.StatusCode} + + err := json.Unmarshal(raw, &apiErr) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &apiErr +} + +func createBaseURL(tenant, server string) (*url.URL, error) { + if tenant == "" { + return nil, errors.New("missing tenant name") + } + + if server == "" { + server = defaultServer + } + + baseURL, err := url.Parse(fmt.Sprintf("https://%s.%s", tenant, server)) + if err != nil { + return nil, fmt.Errorf("parse base URL: %w", err) + } + + return baseURL, nil +} diff --git a/providers/dns/f5xc/internal/client_test.go b/providers/dns/f5xc/internal/client_test.go new file mode 100644 index 000000000..bb188ef3f --- /dev/null +++ b/providers/dns/f5xc/internal/client_test.go @@ -0,0 +1,291 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret", "shortname", "") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("APIToken secret")) +} + +func TestClient_CreateRRSet(t *testing.T) { + client := mockBuilder(). + Route("POST /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA", + servermock.ResponseFromFixture("create.json"), + servermock.CheckRequestJSONBody(`{"dns_zone_name":"example.com","group_name":"groupA","rrset":{"description":"lego","ttl":60,"txt_record":{"name":"wwww","values":["txt"]}}}`)). + Build(t) + + rrSet := RRSet{ + Description: "lego", + TTL: 60, + TXTRecord: &TXTRecord{ + Name: "wwww", + Values: []string{"txt"}, + }, + } + + result, err := client.CreateRRSet(t.Context(), "example.com", "groupA", rrSet) + require.NoError(t, err) + + expected := &APIRRSet{ + DNSZoneName: "string", + GroupName: "string", + RRSet: RRSet{ + Description: "string", + TXTRecord: &TXTRecord{ + Name: "string", + Values: []string{"string"}, + }, + }, + } + + assert.Equal(t, expected, result) +} + +func TestClient_CreateRRSet_error(t *testing.T) { + client := mockBuilder(). + Route("POST /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA", + servermock.Noop().WithStatusCode(http.StatusBadRequest)). + Build(t) + + rrSet := RRSet{ + Description: "lego", + TTL: 60, + TXTRecord: &TXTRecord{ + Name: "wwww", + Values: []string{"txt"}, + }, + } + + _, err := client.CreateRRSet(t.Context(), "example.com", "groupA", rrSet) + require.Error(t, err) +} + +func TestClient_GetRRSet(t *testing.T) { + client := mockBuilder(). + Route("GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", + servermock.ResponseFromFixture("get.json")). + Build(t) + + result, err := client.GetRRSet(t.Context(), "example.com", "groupA", "www", "TXT") + require.NoError(t, err) + + expected := &APIRRSet{ + DNSZoneName: "string", + GroupName: "string", + Namespace: "string", + RecordName: "string", + Type: "string", + RRSet: RRSet{ + Description: "string", + TXTRecord: &TXTRecord{ + Name: "string", + Values: []string{"string"}, + }, + }, + } + + assert.Equal(t, expected, result) +} + +func TestClient_GetRRSet_not_found(t *testing.T) { + client := mockBuilder(). + Route("GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", + servermock.ResponseFromFixture("error_404.json").WithStatusCode(http.StatusNotFound)). + Build(t) + + result, err := client.GetRRSet(t.Context(), "example.com", "groupA", "www", "TXT") + require.NoError(t, err) + + assert.Nil(t, result) +} + +func TestClient_GetRRSet_error(t *testing.T) { + client := mockBuilder(). + Route("GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", + servermock.Noop().WithStatusCode(http.StatusBadRequest)). + Build(t) + + _, err := client.GetRRSet(t.Context(), "example.com", "groupA", "www", "TXT") + require.Error(t, err) +} + +func TestClient_DeleteRRSet(t *testing.T) { + client := mockBuilder(). + Route("DELETE /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", + servermock.ResponseFromFixture("get.json")). + Build(t) + + result, err := client.DeleteRRSet(t.Context(), "example.com", "groupA", "www", "TXT") + require.NoError(t, err) + + expected := &APIRRSet{ + DNSZoneName: "string", + GroupName: "string", + Namespace: "string", + RecordName: "string", + Type: "string", + RRSet: RRSet{ + Description: "string", + TXTRecord: &TXTRecord{ + Name: "string", + Values: []string{"string"}, + }, + }, + } + + assert.Equal(t, expected, result) +} + +func TestClient_DeleteRRSet_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", + servermock.Noop().WithStatusCode(http.StatusBadRequest)). + Build(t) + + _, err := client.DeleteRRSet(t.Context(), "example.com", "groupA", "www", "TXT") + require.Error(t, err) +} + +func TestClient_ReplaceRRSet(t *testing.T) { + client := mockBuilder(). + Route("PUT /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", + servermock.ResponseFromFixture("get.json"), + servermock.CheckRequestJSONBody(`{"dns_zone_name":"example.com","group_name":"groupA","type":"TXT","rrset":{"description":"lego","ttl":60,"txt_record":{"name":"wwww","values":["txt"]}}}`)). + Build(t) + + rrSet := RRSet{ + Description: "lego", + TTL: 60, + TXTRecord: &TXTRecord{ + Name: "wwww", + Values: []string{"txt"}, + }, + } + + result, err := client.ReplaceRRSet(t.Context(), "example.com", "groupA", "www", "TXT", rrSet) + require.NoError(t, err) + + expected := &APIRRSet{ + DNSZoneName: "string", + GroupName: "string", + Namespace: "string", + RecordName: "string", + Type: "string", + RRSet: RRSet{ + Description: "string", + TXTRecord: &TXTRecord{ + Name: "string", + Values: []string{"string"}, + }, + }, + } + + assert.Equal(t, expected, result) +} + +func TestClient_ReplaceRRSet_error(t *testing.T) { + client := mockBuilder(). + Route("PUT /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", + servermock.Noop().WithStatusCode(http.StatusBadRequest)). + Build(t) + + rrSet := RRSet{ + Description: "lego", + TTL: 60, + TXTRecord: &TXTRecord{ + Name: "wwww", + Values: []string{"txt"}, + }, + } + + _, err := client.ReplaceRRSet(t.Context(), "example.com", "groupA", "www", "TXT", rrSet) + require.Error(t, err) +} + +func Test_createBaseURL(t *testing.T) { + testCases := []struct { + desc string + tenant string + server string + expected string + }{ + { + desc: "only tenant", + tenant: "foo", + expected: "https://foo.console.ves.volterra.io", + }, + { + desc: "custom server", + tenant: "foo", + server: "example.com", + expected: "https://foo.example.com", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + baseURL, err := createBaseURL(test.tenant, test.server) + require.NoError(t, err) + + assert.Equal(t, test.expected, baseURL.String()) + }) + } +} + +func Test_createBaseURL_error(t *testing.T) { + testCases := []struct { + desc string + tenant string + server string + expected string + }{ + { + desc: "no tenant", + tenant: "", + expected: "missing tenant name", + }, + { + desc: "invalid tenant", + tenant: "%31", + expected: `parse base URL: parse "https://%31.console.ves.volterra.io": invalid URL escape "%31"`, + }, + { + desc: "invalid host", + tenant: "foo", + server: "192.168.0.%31", + expected: `parse base URL: parse "https://foo.192.168.0.%31": invalid URL escape "%31"`, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + _, err := createBaseURL(test.tenant, test.server) + require.EqualError(t, err, test.expected) + }) + } +} diff --git a/providers/dns/f5xc/internal/fixtures/create.json b/providers/dns/f5xc/internal/fixtures/create.json new file mode 100644 index 000000000..8c852304d --- /dev/null +++ b/providers/dns/f5xc/internal/fixtures/create.json @@ -0,0 +1,204 @@ +{ + "dns_zone_name": "string", + "group_name": "string", + "rrset": { + "a_record": { + "name": "string", + "values": [ + "string" + ] + }, + "aaaa_record": { + "name": "string", + "values": [ + "string" + ] + }, + "afsdb_record": { + "name": "string", + "values": [ + { + "hostname": "string", + "subtype": "NONE" + } + ] + }, + "alias_record": { + "value": "string" + }, + "caa_record": { + "name": "string", + "values": [ + { + "flags": 0, + "tag": "string", + "value": "string" + } + ] + }, + "cds_record": { + "name": "string", + "values": [ + { + "ds_key_algorithm": "UNSPECIFIED", + "key_tag": 0, + "sha1_digest": { + "digest": "stringstringstringstringstringstringstri" + }, + "sha256_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstri" + }, + "sha384_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" + } + } + ] + }, + "cert_record": { + "name": "string", + "values": [ + { + "algorithm": "RESERVEDALGORITHM", + "cert_key_tag": 0, + "cert_type": "INVALIDCERTTYPE", + "certificate": "string" + } + ] + }, + "cname_record": { + "name": "string", + "value": "string" + }, + "description": "string", + "ds_record": { + "name": "string", + "values": [ + { + "ds_key_algorithm": "UNSPECIFIED", + "key_tag": 0, + "sha1_digest": { + "digest": "stringstringstringstringstringstringstri" + }, + "sha256_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstri" + }, + "sha384_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" + } + } + ] + }, + "eui48_record": { + "name": "string", + "value": "stringstringstrin" + }, + "eui64_record": { + "name": "string", + "value": "stringstringstringstrin" + }, + "lb_record": { + "name": "string", + "value": { + "name": "string", + "namespace": "string", + "tenant": "string" + } + }, + "loc_record": { + "name": "string", + "values": [ + { + "altitude": 0.1, + "horizontal_precision": 0.1, + "latitude_degree": 0, + "latitude_hemisphere": "N", + "latitude_minute": 0, + "latitude_second": 0.1, + "location_diameter": 0.1, + "longitude_degree": 0, + "longitude_hemisphere": "E", + "longitude_minute": 0, + "longitude_second": 0.1, + "vertical_precision": 0.1 + } + ] + }, + "mx_record": { + "name": "string", + "values": [ + { + "domain": "string", + "priority": 0 + } + ] + }, + "naptr_record": { + "name": "string", + "values": [ + { + "flags": "string", + "order": 0, + "preference": 0, + "regexp": "string", + "replacement": "string", + "service": "string" + } + ] + }, + "ns_record": { + "name": "string", + "values": [ + "string" + ] + }, + "ptr_record": { + "name": "string", + "values": [ + "string" + ] + }, + "srv_record": { + "name": "string", + "values": [ + { + "port": 0, + "priority": 0, + "target": "string", + "weight": 0 + } + ] + }, + "sshfp_record": { + "name": "string", + "values": [ + { + "algorithm": "UNSPECIFIEDALGORITHM", + "sha1_fingerprint": { + "fingerprint": "stringstringstringstringstringstringstri" + }, + "sha256_fingerprint": { + "fingerprint": "stringstringstringstringstringstringstringstringstringstringstri" + } + } + ] + }, + "tlsa_record": { + "name": "string", + "values": [ + { + "certificate_association_data": "string", + "certificate_usage": "CertificateAuthorityConstraint", + "matching_type": "NoHash", + "selector": "FullCertificate" + } + ] + }, + "ttl": 0, + "txt_record": { + "name": "string", + "values": [ + "string" + ] + } + } +} diff --git a/providers/dns/f5xc/internal/fixtures/delete.json b/providers/dns/f5xc/internal/fixtures/delete.json new file mode 100644 index 000000000..5c5143cae --- /dev/null +++ b/providers/dns/f5xc/internal/fixtures/delete.json @@ -0,0 +1,207 @@ +{ + "dns_zone_name": "string", + "group_name": "string", + "namespace": "string", + "record_name": "string", + "rrset": { + "a_record": { + "name": "string", + "values": [ + "string" + ] + }, + "aaaa_record": { + "name": "string", + "values": [ + "string" + ] + }, + "afsdb_record": { + "name": "string", + "values": [ + { + "hostname": "string", + "subtype": "NONE" + } + ] + }, + "alias_record": { + "value": "string" + }, + "caa_record": { + "name": "string", + "values": [ + { + "flags": 0, + "tag": "string", + "value": "string" + } + ] + }, + "cds_record": { + "name": "string", + "values": [ + { + "ds_key_algorithm": "UNSPECIFIED", + "key_tag": 0, + "sha1_digest": { + "digest": "stringstringstringstringstringstringstri" + }, + "sha256_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstri" + }, + "sha384_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" + } + } + ] + }, + "cert_record": { + "name": "string", + "values": [ + { + "algorithm": "RESERVEDALGORITHM", + "cert_key_tag": 0, + "cert_type": "INVALIDCERTTYPE", + "certificate": "string" + } + ] + }, + "cname_record": { + "name": "string", + "value": "string" + }, + "description": "string", + "ds_record": { + "name": "string", + "values": [ + { + "ds_key_algorithm": "UNSPECIFIED", + "key_tag": 0, + "sha1_digest": { + "digest": "stringstringstringstringstringstringstri" + }, + "sha256_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstri" + }, + "sha384_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" + } + } + ] + }, + "eui48_record": { + "name": "string", + "value": "stringstringstrin" + }, + "eui64_record": { + "name": "string", + "value": "stringstringstringstrin" + }, + "lb_record": { + "name": "string", + "value": { + "name": "string", + "namespace": "string", + "tenant": "string" + } + }, + "loc_record": { + "name": "string", + "values": [ + { + "altitude": 0.1, + "horizontal_precision": 0.1, + "latitude_degree": 0, + "latitude_hemisphere": "N", + "latitude_minute": 0, + "latitude_second": 0.1, + "location_diameter": 0.1, + "longitude_degree": 0, + "longitude_hemisphere": "E", + "longitude_minute": 0, + "longitude_second": 0.1, + "vertical_precision": 0.1 + } + ] + }, + "mx_record": { + "name": "string", + "values": [ + { + "domain": "string", + "priority": 0 + } + ] + }, + "naptr_record": { + "name": "string", + "values": [ + { + "flags": "string", + "order": 0, + "preference": 0, + "regexp": "string", + "replacement": "string", + "service": "string" + } + ] + }, + "ns_record": { + "name": "string", + "values": [ + "string" + ] + }, + "ptr_record": { + "name": "string", + "values": [ + "string" + ] + }, + "srv_record": { + "name": "string", + "values": [ + { + "port": 0, + "priority": 0, + "target": "string", + "weight": 0 + } + ] + }, + "sshfp_record": { + "name": "string", + "values": [ + { + "algorithm": "UNSPECIFIEDALGORITHM", + "sha1_fingerprint": { + "fingerprint": "stringstringstringstringstringstringstri" + }, + "sha256_fingerprint": { + "fingerprint": "stringstringstringstringstringstringstringstringstringstringstri" + } + } + ] + }, + "tlsa_record": { + "name": "string", + "values": [ + { + "certificate_association_data": "string", + "certificate_usage": "CertificateAuthorityConstraint", + "matching_type": "NoHash", + "selector": "FullCertificate" + } + ] + }, + "ttl": 0, + "txt_record": { + "name": "string", + "values": [ + "string" + ] + } + }, + "type": "string" +} diff --git a/providers/dns/f5xc/internal/fixtures/error_404.json b/providers/dns/f5xc/internal/fixtures/error_404.json new file mode 100644 index 000000000..4abd79dd4 --- /dev/null +++ b/providers/dns/f5xc/internal/fixtures/error_404.json @@ -0,0 +1,5 @@ +{ + "code": 5, + "details": [], + "message": "the requested resource record was not found: (group,name,type) (acme-records,_acme-challenge,TXT)" +} diff --git a/providers/dns/f5xc/internal/fixtures/error_503.json b/providers/dns/f5xc/internal/fixtures/error_503.json new file mode 100644 index 000000000..8d286a2a0 --- /dev/null +++ b/providers/dns/f5xc/internal/fixtures/error_503.json @@ -0,0 +1,5 @@ +{ + "code": 14, + "details": [], + "message": "Previous DNS zone change is pending. Try again later" +} diff --git a/providers/dns/f5xc/internal/fixtures/get.json b/providers/dns/f5xc/internal/fixtures/get.json new file mode 100644 index 000000000..5c5143cae --- /dev/null +++ b/providers/dns/f5xc/internal/fixtures/get.json @@ -0,0 +1,207 @@ +{ + "dns_zone_name": "string", + "group_name": "string", + "namespace": "string", + "record_name": "string", + "rrset": { + "a_record": { + "name": "string", + "values": [ + "string" + ] + }, + "aaaa_record": { + "name": "string", + "values": [ + "string" + ] + }, + "afsdb_record": { + "name": "string", + "values": [ + { + "hostname": "string", + "subtype": "NONE" + } + ] + }, + "alias_record": { + "value": "string" + }, + "caa_record": { + "name": "string", + "values": [ + { + "flags": 0, + "tag": "string", + "value": "string" + } + ] + }, + "cds_record": { + "name": "string", + "values": [ + { + "ds_key_algorithm": "UNSPECIFIED", + "key_tag": 0, + "sha1_digest": { + "digest": "stringstringstringstringstringstringstri" + }, + "sha256_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstri" + }, + "sha384_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" + } + } + ] + }, + "cert_record": { + "name": "string", + "values": [ + { + "algorithm": "RESERVEDALGORITHM", + "cert_key_tag": 0, + "cert_type": "INVALIDCERTTYPE", + "certificate": "string" + } + ] + }, + "cname_record": { + "name": "string", + "value": "string" + }, + "description": "string", + "ds_record": { + "name": "string", + "values": [ + { + "ds_key_algorithm": "UNSPECIFIED", + "key_tag": 0, + "sha1_digest": { + "digest": "stringstringstringstringstringstringstri" + }, + "sha256_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstri" + }, + "sha384_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" + } + } + ] + }, + "eui48_record": { + "name": "string", + "value": "stringstringstrin" + }, + "eui64_record": { + "name": "string", + "value": "stringstringstringstrin" + }, + "lb_record": { + "name": "string", + "value": { + "name": "string", + "namespace": "string", + "tenant": "string" + } + }, + "loc_record": { + "name": "string", + "values": [ + { + "altitude": 0.1, + "horizontal_precision": 0.1, + "latitude_degree": 0, + "latitude_hemisphere": "N", + "latitude_minute": 0, + "latitude_second": 0.1, + "location_diameter": 0.1, + "longitude_degree": 0, + "longitude_hemisphere": "E", + "longitude_minute": 0, + "longitude_second": 0.1, + "vertical_precision": 0.1 + } + ] + }, + "mx_record": { + "name": "string", + "values": [ + { + "domain": "string", + "priority": 0 + } + ] + }, + "naptr_record": { + "name": "string", + "values": [ + { + "flags": "string", + "order": 0, + "preference": 0, + "regexp": "string", + "replacement": "string", + "service": "string" + } + ] + }, + "ns_record": { + "name": "string", + "values": [ + "string" + ] + }, + "ptr_record": { + "name": "string", + "values": [ + "string" + ] + }, + "srv_record": { + "name": "string", + "values": [ + { + "port": 0, + "priority": 0, + "target": "string", + "weight": 0 + } + ] + }, + "sshfp_record": { + "name": "string", + "values": [ + { + "algorithm": "UNSPECIFIEDALGORITHM", + "sha1_fingerprint": { + "fingerprint": "stringstringstringstringstringstringstri" + }, + "sha256_fingerprint": { + "fingerprint": "stringstringstringstringstringstringstringstringstringstringstri" + } + } + ] + }, + "tlsa_record": { + "name": "string", + "values": [ + { + "certificate_association_data": "string", + "certificate_usage": "CertificateAuthorityConstraint", + "matching_type": "NoHash", + "selector": "FullCertificate" + } + ] + }, + "ttl": 0, + "txt_record": { + "name": "string", + "values": [ + "string" + ] + } + }, + "type": "string" +} diff --git a/providers/dns/f5xc/internal/fixtures/replace.json b/providers/dns/f5xc/internal/fixtures/replace.json new file mode 100644 index 000000000..e3e483df5 --- /dev/null +++ b/providers/dns/f5xc/internal/fixtures/replace.json @@ -0,0 +1,206 @@ +{ + "dns_zone_name": "string", + "group_name": "string", + "record_name": "string", + "rrset": { + "a_record": { + "name": "string", + "values": [ + "string" + ] + }, + "aaaa_record": { + "name": "string", + "values": [ + "string" + ] + }, + "afsdb_record": { + "name": "string", + "values": [ + { + "hostname": "string", + "subtype": "NONE" + } + ] + }, + "alias_record": { + "value": "string" + }, + "caa_record": { + "name": "string", + "values": [ + { + "flags": 0, + "tag": "string", + "value": "string" + } + ] + }, + "cds_record": { + "name": "string", + "values": [ + { + "ds_key_algorithm": "UNSPECIFIED", + "key_tag": 0, + "sha1_digest": { + "digest": "stringstringstringstringstringstringstri" + }, + "sha256_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstri" + }, + "sha384_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" + } + } + ] + }, + "cert_record": { + "name": "string", + "values": [ + { + "algorithm": "RESERVEDALGORITHM", + "cert_key_tag": 0, + "cert_type": "INVALIDCERTTYPE", + "certificate": "string" + } + ] + }, + "cname_record": { + "name": "string", + "value": "string" + }, + "description": "string", + "ds_record": { + "name": "string", + "values": [ + { + "ds_key_algorithm": "UNSPECIFIED", + "key_tag": 0, + "sha1_digest": { + "digest": "stringstringstringstringstringstringstri" + }, + "sha256_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstri" + }, + "sha384_digest": { + "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" + } + } + ] + }, + "eui48_record": { + "name": "string", + "value": "stringstringstrin" + }, + "eui64_record": { + "name": "string", + "value": "stringstringstringstrin" + }, + "lb_record": { + "name": "string", + "value": { + "name": "string", + "namespace": "string", + "tenant": "string" + } + }, + "loc_record": { + "name": "string", + "values": [ + { + "altitude": 0.1, + "horizontal_precision": 0.1, + "latitude_degree": 0, + "latitude_hemisphere": "N", + "latitude_minute": 0, + "latitude_second": 0.1, + "location_diameter": 0.1, + "longitude_degree": 0, + "longitude_hemisphere": "E", + "longitude_minute": 0, + "longitude_second": 0.1, + "vertical_precision": 0.1 + } + ] + }, + "mx_record": { + "name": "string", + "values": [ + { + "domain": "string", + "priority": 0 + } + ] + }, + "naptr_record": { + "name": "string", + "values": [ + { + "flags": "string", + "order": 0, + "preference": 0, + "regexp": "string", + "replacement": "string", + "service": "string" + } + ] + }, + "ns_record": { + "name": "string", + "values": [ + "string" + ] + }, + "ptr_record": { + "name": "string", + "values": [ + "string" + ] + }, + "srv_record": { + "name": "string", + "values": [ + { + "port": 0, + "priority": 0, + "target": "string", + "weight": 0 + } + ] + }, + "sshfp_record": { + "name": "string", + "values": [ + { + "algorithm": "UNSPECIFIEDALGORITHM", + "sha1_fingerprint": { + "fingerprint": "stringstringstringstringstringstringstri" + }, + "sha256_fingerprint": { + "fingerprint": "stringstringstringstringstringstringstringstringstringstringstri" + } + } + ] + }, + "tlsa_record": { + "name": "string", + "values": [ + { + "certificate_association_data": "string", + "certificate_usage": "CertificateAuthorityConstraint", + "matching_type": "NoHash", + "selector": "FullCertificate" + } + ] + }, + "ttl": 0, + "txt_record": { + "name": "string", + "values": [ + "string" + ] + } + }, + "type": "string" +} diff --git a/providers/dns/f5xc/internal/types.go b/providers/dns/f5xc/internal/types.go new file mode 100644 index 000000000..346283fb7 --- /dev/null +++ b/providers/dns/f5xc/internal/types.go @@ -0,0 +1,48 @@ +package internal + +import ( + "fmt" + "strings" +) + +type APIError struct { + StatusCode int `json:"-"` + Code int `json:"code"` + Details []string `json:"details"` + Message string `json:"message"` +} + +func (a *APIError) Error() string { + var details string + if len(a.Details) > 0 { + details = " " + strings.Join(a.Details, ", ") + } + + return fmt.Sprintf("code: %d, message: %s%s", a.Code, a.Message, details) +} + +type APIRRSet struct { + DNSZoneName string `json:"dns_zone_name,omitempty"` + GroupName string `json:"group_name,omitempty"` + Namespace string `json:"namespace,omitempty"` + RecordName string `json:"record_name,omitempty"` + Type string `json:"type,omitempty"` + RRSet RRSet `json:"rrset"` +} + +type RRSetRequest struct { + DNSZoneName string `json:"dns_zone_name,omitempty"` + GroupName string `json:"group_name,omitempty"` + RRSet RRSet `json:"rrset"` +} + +type RRSet struct { + Description string `json:"description,omitempty"` + TTL int `json:"ttl,omitempty"` + TXTRecord *TXTRecord `json:"txt_record,omitempty"` +} + +type TXTRecord struct { + Name string `json:"name,omitempty"` + Values []string `json:"values,omitempty"` +} diff --git a/providers/dns/freemyip/freemyip.go b/providers/dns/freemyip/freemyip.go index 18fcb0565..fb6202e25 100644 --- a/providers/dns/freemyip/freemyip.go +++ b/providers/dns/freemyip/freemyip.go @@ -8,8 +8,10 @@ import ( "net/http" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/nrdcg/freemyip" ) @@ -26,6 +28,8 @@ const ( EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Token string @@ -85,6 +89,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, diff --git a/providers/dns/freemyip/freemyip.toml b/providers/dns/freemyip/freemyip.toml index a71538ee3..adbf9e213 100644 --- a/providers/dns/freemyip/freemyip.toml +++ b/providers/dns/freemyip/freemyip.toml @@ -6,18 +6,18 @@ Since = "v4.5.0" Example = ''' FREEMYIP_TOKEN=xxxxxx \ -lego --email you@example.com --dns freemyip -d '*.example.com' -d example.com run +lego --dns freemyip -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] FREEMYIP_TOKEN = "Account token" [Configuration.Additional] - FREEMYIP_POLLING_INTERVAL = "Time between DNS propagation check" - FREEMYIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - FREEMYIP_TTL = "The TTL of the TXT record used for the DNS challenge" - FREEMYIP_HTTP_TIMEOUT = "API request timeout" - FREEMYIP_SEQUENCE_INTERVAL = "Time between sequential requests" + FREEMYIP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + FREEMYIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + FREEMYIP_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" + FREEMYIP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + FREEMYIP_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" [Links] API = "https://freemyip.com/help" diff --git a/providers/dns/freemyip/freemyip_test.go b/providers/dns/freemyip/freemyip_test.go index dcf74dd6c..24d1b98f7 100644 --- a/providers/dns/freemyip/freemyip_test.go +++ b/providers/dns/freemyip/freemyip_test.go @@ -37,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -94,6 +95,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -107,6 +109,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/gandi/gandi.go b/providers/dns/gandi/gandi.go index 93e94f276..bb96a7d0f 100644 --- a/providers/dns/gandi/gandi.go +++ b/providers/dns/gandi/gandi.go @@ -9,16 +9,13 @@ import ( "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/gandi/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) -// Gandi API reference: http://doc.rpc.gandi.net/index.html -// Gandi API domain examples: http://doc.rpc.gandi.net/domain/faq.html - -const minTTL = 300 - // Environment variables names. const ( envNamespace = "GANDI_" @@ -31,6 +28,10 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const minTTL = 300 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string @@ -109,6 +110,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, diff --git a/providers/dns/gandi/gandi.toml b/providers/dns/gandi/gandi.toml index be5bc00d2..23d7de5db 100644 --- a/providers/dns/gandi/gandi.toml +++ b/providers/dns/gandi/gandi.toml @@ -6,17 +6,17 @@ Since = "v0.3.0" Example = ''' GANDI_API_KEY=abcdefghijklmnopqrstuvwx \ -lego --email you@example.com --dns gandi -d '*.example.com' -d example.com run +lego --dns gandi -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] GANDI_API_KEY = "API key" [Configuration.Additional] - GANDI_POLLING_INTERVAL = "Time between DNS propagation check" - GANDI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - GANDI_TTL = "The TTL of the TXT record used for the DNS challenge" - GANDI_HTTP_TIMEOUT = "API request timeout" + GANDI_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 60)" + GANDI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 2400)" + GANDI_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + GANDI_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" [Links] API = "https://doc.rpc.gandi.net/index.html" diff --git a/providers/dns/gandi/gandi_test.go b/providers/dns/gandi/gandi_test.go index 36bc4ccd2..58c25d0db 100644 --- a/providers/dns/gandi/gandi_test.go +++ b/providers/dns/gandi/gandi_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) @@ -38,6 +39,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -119,47 +121,52 @@ func TestDNSProvider(t *testing.T) { cleanupDeleteZoneRequestMock: cleanupDeleteZoneResponseMock, } - fakeKeyAuth := "XXXX" - regexpDate := regexp.MustCompile(`\[ACME Challenge [^\]:]*:[^\]]*\]`) - // start fake RPC server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "text/xml", r.Header.Get("Content-Type"), "invalid content type") + provider := servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.BaseURL = server.URL + "/" + config.HTTPClient = server.Client() + config.APIKey = "123412341234123412341234" - req, errS := io.ReadAll(r.Body) - require.NoError(t, errS) + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader().WithContentType("text/xml"), + ). + Route("POST /", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + require.Equal(t, "text/xml", req.Header.Get("Content-Type"), "invalid content type") - req = regexpDate.ReplaceAllLiteral(req, []byte(`[ACME Challenge 01 Jan 16 00:00 +0000]`)) - resp, ok := serverResponses[string(req)] - require.Truef(t, ok, "Server response for request not found: %s", string(req)) + body, errS := io.ReadAll(req.Body) + require.NoError(t, errS) - _, errS = io.Copy(w, strings.NewReader(resp)) - require.NoError(t, errS) - })) - t.Cleanup(server.Close) + body = regexpDate.ReplaceAllLiteral(body, []byte(`[ACME Challenge 01 Jan 16 00:00 +0000]`)) + resp, ok := serverResponses[string(body)] + require.Truef(t, ok, "Server response for request not found: %s", string(body)) + + _, errS = io.Copy(rw, strings.NewReader(resp)) + require.NoError(t, errS) + })). + Build(t) + + fakeKeyAuth := "XXXX" // define function to override findZoneByFqdn with fakeFindZoneByFqdn := func(fqdn string) (string, error) { return "example.com.", nil } - config := NewDefaultConfig() - config.BaseURL = server.URL + "/" - config.APIKey = "123412341234123412341234" - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - // override findZoneByFqdn function savedFindZoneByFqdn := provider.findZoneByFqdn + t.Cleanup(func() { provider.findZoneByFqdn = savedFindZoneByFqdn }) + provider.findZoneByFqdn = fakeFindZoneByFqdn // run Present - err = provider.Present("abc.def.example.com", "", fakeKeyAuth) + err := provider.Present("abc.def.example.com", "", fakeKeyAuth) require.NoError(t, err) // run CleanUp diff --git a/providers/dns/gandi/internal/client.go b/providers/dns/gandi/internal/client.go index 6dc09648c..6ca46d072 100644 --- a/providers/dns/gandi/internal/client.go +++ b/providers/dns/gandi/internal/client.go @@ -50,6 +50,7 @@ func (c *Client) GetZoneID(ctx context.Context, domain string) (int, error) { } var zoneID int + for _, member := range resp.StructMembers { if member.Name == "zone_id" { zoneID = member.ValueInt @@ -59,6 +60,7 @@ func (c *Client) GetZoneID(ctx context.Context, domain string) (int, error) { if zoneID == 0 { return 0, fmt.Errorf("could not find zone_id for %s", domain) } + return zoneID, nil } @@ -88,6 +90,7 @@ func (c *Client) CloneZone(ctx context.Context, zoneID int, name string) (int, e } var newZoneID int + for _, member := range resp.StructMembers { if member.Name == "id" { newZoneID = member.ValueInt @@ -97,6 +100,7 @@ func (c *Client) CloneZone(ctx context.Context, zoneID int, name string) (int, e if newZoneID == 0 { return 0, errors.New("could not determine cloned zone_id") } + return newZoneID, nil } @@ -119,6 +123,7 @@ func (c *Client) NewZoneVersion(ctx context.Context, zoneID int) (int, error) { if resp.Value == 0 { return 0, errors.New("could not create new zone version") } + return resp.Value, nil } @@ -174,6 +179,7 @@ func (c *Client) SetZoneVersion(ctx context.Context, zoneID, version int) error if !resp.Value { return errors.New("could not set zone version") } + return nil } @@ -195,6 +201,7 @@ func (c *Client) SetZone(ctx context.Context, domain string, zoneID int) error { } var respZoneID int + for _, member := range resp.StructMembers { if member.Name == "zone_id" { respZoneID = member.ValueInt @@ -204,6 +211,7 @@ func (c *Client) SetZone(ctx context.Context, domain string, zoneID int) error { if respZoneID != zoneID { return fmt.Errorf("could not set new zone_id for %s", domain) } + return nil } diff --git a/providers/dns/gandi/internal/client_test.go b/providers/dns/gandi/internal/client_test.go new file mode 100644 index 000000000..a800767a2 --- /dev/null +++ b/providers/dns/gandi/internal/client_test.go @@ -0,0 +1,99 @@ +package internal + +import ( + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.BaseURL = server.URL + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader().WithContentType("text/xml"), + ) +} + +func TestClient_GetZoneID(t *testing.T) { + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("get_zone_id.xml"), + servermock.CheckRequestBodyFromFixture("get_zone_id-request.xml").IgnoreWhitespace()). + Build(t) + + zoneID, err := client.GetZoneID(t.Context(), "example.com") + require.NoError(t, err) + + assert.Equal(t, 1, zoneID) +} + +func TestClient_CloneZone(t *testing.T) { + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("clone_zone.xml"), + servermock.CheckRequestBodyFromFixture("clone_zone-request.xml").IgnoreWhitespace()). + Build(t) + + zoneID, err := client.CloneZone(t.Context(), 6, "foo") + require.NoError(t, err) + + assert.Equal(t, 1, zoneID) +} + +func TestClient_NewZoneVersion(t *testing.T) { + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("new_zone_version.xml"), + servermock.CheckRequestBodyFromFixture("new_zone_version-request.xml").IgnoreWhitespace()). + Build(t) + + zoneID, err := client.NewZoneVersion(t.Context(), 6) + require.NoError(t, err) + + assert.Equal(t, 1, zoneID) +} + +func TestClient_AddTXTRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("empty.xml"), + servermock.CheckRequestBodyFromFixture("add_txt_record-request.xml").IgnoreWhitespace()). + Build(t) + + err := client.AddTXTRecord(t.Context(), 1, 123, "foo", "content", 120) + require.NoError(t, err) +} + +func TestClient_SetZoneVersion(t *testing.T) { + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("set_zone_version.xml"), + servermock.CheckRequestBodyFromFixture("set_zone_version-request.xml").IgnoreWhitespace()). + Build(t) + + err := client.SetZoneVersion(t.Context(), 1, 123) + require.NoError(t, err) +} + +func TestClient_SetZone(t *testing.T) { + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("set_zone.xml"), + servermock.CheckRequestBodyFromFixture("set_zone-request.xml").IgnoreWhitespace()). + Build(t) + + err := client.SetZone(t.Context(), "example.com", 1) + require.NoError(t, err) +} + +func TestClient_DeleteZone(t *testing.T) { + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("delete_zone.xml"), + servermock.CheckRequestBodyFromFixture("delete_zone-request.xml").IgnoreWhitespace()). + Build(t) + + err := client.DeleteZone(t.Context(), 1) + require.NoError(t, err) +} diff --git a/providers/dns/gandi/internal/fixtures/add_txt_record-request.xml b/providers/dns/gandi/internal/fixtures/add_txt_record-request.xml new file mode 100644 index 000000000..001ee7a33 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/add_txt_record-request.xml @@ -0,0 +1,49 @@ + + + domain.zone.record.add + + + secret + + + + + 1 + + + + + 123 + + + + + + + type + + TXT + + + + name + + foo + + + + value + + content + + + + ttl + + 120 + + + + + + diff --git a/providers/dns/gandi/internal/fixtures/clone_zone-request.xml b/providers/dns/gandi/internal/fixtures/clone_zone-request.xml new file mode 100644 index 000000000..40ee87c7e --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/clone_zone-request.xml @@ -0,0 +1,31 @@ + + + domain.zone.clone + + + secret + + + + + 6 + + + + + 0 + + + + + + + name + + foo + + + + + + diff --git a/providers/dns/gandi/internal/fixtures/clone_zone.xml b/providers/dns/gandi/internal/fixtures/clone_zone.xml new file mode 100644 index 000000000..2af93526e --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/clone_zone.xml @@ -0,0 +1,22 @@ + + + + + + + id + + 1 + + + + foo + + 2 + + + + + + + diff --git a/providers/dns/gandi/internal/fixtures/delete_zone-request.xml b/providers/dns/gandi/internal/fixtures/delete_zone-request.xml new file mode 100644 index 000000000..0ba9cb766 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/delete_zone-request.xml @@ -0,0 +1,14 @@ + + + domain.zone.delete + + + secret + + + + + 1 + + + diff --git a/providers/dns/gandi/internal/fixtures/delete_zone.xml b/providers/dns/gandi/internal/fixtures/delete_zone.xml new file mode 100644 index 000000000..28ba00dc5 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/delete_zone.xml @@ -0,0 +1,9 @@ + + + + + true + + + + diff --git a/providers/dns/gandi/internal/fixtures/empty.xml b/providers/dns/gandi/internal/fixtures/empty.xml new file mode 100644 index 000000000..7843fd723 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/empty.xml @@ -0,0 +1,2 @@ + + diff --git a/providers/dns/gandi/internal/fixtures/get_zone_id-request.xml b/providers/dns/gandi/internal/fixtures/get_zone_id-request.xml new file mode 100644 index 000000000..173a725d8 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/get_zone_id-request.xml @@ -0,0 +1,14 @@ + + + domain.info + + + secret + + + + + example.com + + + diff --git a/providers/dns/gandi/internal/fixtures/get_zone_id.xml b/providers/dns/gandi/internal/fixtures/get_zone_id.xml new file mode 100644 index 000000000..2a11e0dff --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/get_zone_id.xml @@ -0,0 +1,22 @@ + + + + + + + zone_id + + 1 + + + + foo + + 2 + + + + + + + diff --git a/providers/dns/gandi/internal/fixtures/new_zone_version-request.xml b/providers/dns/gandi/internal/fixtures/new_zone_version-request.xml new file mode 100644 index 000000000..2fbac82de --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/new_zone_version-request.xml @@ -0,0 +1,14 @@ + + + domain.zone.version.new + + + secret + + + + + 6 + + + diff --git a/providers/dns/gandi/internal/fixtures/new_zone_version.xml b/providers/dns/gandi/internal/fixtures/new_zone_version.xml new file mode 100644 index 000000000..feb84e486 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/new_zone_version.xml @@ -0,0 +1,9 @@ + + + + + 1 + + + + diff --git a/providers/dns/gandi/internal/fixtures/set_zone-request.xml b/providers/dns/gandi/internal/fixtures/set_zone-request.xml new file mode 100644 index 000000000..71ac843fd --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/set_zone-request.xml @@ -0,0 +1,19 @@ + + + domain.zone.set + + + secret + + + + + example.com + + + + + 1 + + + diff --git a/providers/dns/gandi/internal/fixtures/set_zone.xml b/providers/dns/gandi/internal/fixtures/set_zone.xml new file mode 100644 index 000000000..2a11e0dff --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/set_zone.xml @@ -0,0 +1,22 @@ + + + + + + + zone_id + + 1 + + + + foo + + 2 + + + + + + + diff --git a/providers/dns/gandi/internal/fixtures/set_zone_version-request.xml b/providers/dns/gandi/internal/fixtures/set_zone_version-request.xml new file mode 100644 index 000000000..68a021446 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/set_zone_version-request.xml @@ -0,0 +1,19 @@ + + + domain.zone.version.set + + + secret + + + + + 1 + + + + + 123 + + + diff --git a/providers/dns/gandi/internal/fixtures/set_zone_version.xml b/providers/dns/gandi/internal/fixtures/set_zone_version.xml new file mode 100644 index 000000000..28ba00dc5 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/set_zone_version.xml @@ -0,0 +1,9 @@ + + + + + true + + + + diff --git a/providers/dns/gandi/internal/types.go b/providers/dns/gandi/internal/types.go index cdcd0a658..2cde62b53 100644 --- a/providers/dns/gandi/internal/types.go +++ b/providers/dns/gandi/internal/types.go @@ -69,6 +69,7 @@ func (r responseFault) faultString() string { return r.FaultString } type responseStruct struct { responseFault + StructMembers []struct { Name string `xml:"name"` ValueInt int `xml:"value>int"` @@ -77,11 +78,13 @@ type responseStruct struct { type responseInt struct { responseFault + Value int `xml:"params>param>value>int"` } type responseBool struct { responseFault + Value bool `xml:"params>param>value>boolean"` } diff --git a/providers/dns/gandiv5/gandiv5.go b/providers/dns/gandiv5/gandiv5.go index 8b342592b..15014e207 100644 --- a/providers/dns/gandiv5/gandiv5.go +++ b/providers/dns/gandiv5/gandiv5.go @@ -10,16 +10,14 @@ import ( "sync" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/gandiv5/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) -// Gandi API reference: http://doc.livedns.gandi.net/ - -const minTTL = 300 - // Environment variables names. const ( envNamespace = "GANDIV5_" @@ -33,6 +31,10 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const minTTL = 300 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // inProgressInfo contains information about an in-progress challenge. type inProgressInfo struct { fieldName string @@ -112,6 +114,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if err != nil { return nil, fmt.Errorf("gandiv5: %w", err) } + client.BaseURL = baseURL } @@ -119,6 +122,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -159,6 +164,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone: authZone, fieldName: subDomain, } + return nil } @@ -169,6 +175,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { // acquire lock and retrieve authZone d.inProgressMu.Lock() defer d.inProgressMu.Unlock() + if _, ok := d.inProgressFQDNs[info.EffectiveFQDN]; !ok { // if there is no cleanup information then just return return nil @@ -183,6 +190,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("gandiv5: %w", err) } + return nil } diff --git a/providers/dns/gandiv5/gandiv5.toml b/providers/dns/gandiv5/gandiv5.toml index ebeef84b8..31568e89b 100644 --- a/providers/dns/gandiv5/gandiv5.toml +++ b/providers/dns/gandiv5/gandiv5.toml @@ -6,7 +6,7 @@ Since = "v0.5.0" Example = ''' GANDIV5_PERSONAL_ACCESS_TOKEN=abcdefghijklmnopqrstuvwx \ -lego --email you@example.com --dns gandiv5 -d '*.example.com' -d example.com run +lego --dns gandiv5 -d '*.example.com' -d example.com run ''' [Configuration] @@ -14,10 +14,10 @@ lego --email you@example.com --dns gandiv5 -d '*.example.com' -d example.com run GANDIV5_PERSONAL_ACCESS_TOKEN = "Personal Access Token" GANDIV5_API_KEY = "API key (Deprecated)" [Configuration.Additional] - GANDIV5_POLLING_INTERVAL = "Time between DNS propagation check" - GANDIV5_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - GANDIV5_TTL = "The TTL of the TXT record used for the DNS challenge" - GANDIV5_HTTP_TIMEOUT = "API request timeout" + GANDIV5_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)" + GANDIV5_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 1200)" + GANDIV5_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + GANDIV5_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://api.gandi.net/docs/livedns/" diff --git a/providers/dns/gandiv5/gandiv5_test.go b/providers/dns/gandiv5/gandiv5_test.go index 57fed032e..d6f077243 100644 --- a/providers/dns/gandiv5/gandiv5_test.go +++ b/providers/dns/gandiv5/gandiv5_test.go @@ -1,15 +1,11 @@ package gandiv5 import ( - "fmt" - "io" - "net/http" "net/http/httptest" - "regexp" "testing" - "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) @@ -39,6 +35,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -95,90 +92,44 @@ func TestNewDNSProviderConfig(t *testing.T) { // TestDNSProvider runs Present and CleanUp against a fake Gandi RPC // Server, whose responses are predetermined for particular requests. func TestDNSProvider(t *testing.T) { - // serverResponses is the JSON Request->Response map used by the - // fake JSON server. - serverResponses := map[string]map[string]string{ - http.MethodGet: { - ``: `{"rrset_ttl":300,"rrset_values":[],"rrset_name":"_acme-challenge.abc.def","rrset_type":"TXT"}`, + provider := servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.PersonalAccessToken = "123412341234123412341234" + config.BaseURL = server.URL + config.HTTPClient = server.Client() + + return NewDNSProviderConfig(config) }, - http.MethodPut: { - `{"rrset_ttl":300,"rrset_values":["TOKEN"]}`: `{"message": "Zone Record Created"}`, - }, - http.MethodDelete: { - ``: ``, - }, - } + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer 123412341234123412341234"), + ). + Route("GET /domains/example.com/records/_acme-challenge.abc.def/TXT", + servermock.RawStringResponse(`{"rrset_ttl":300,"rrset_values":[],"rrset_name":"_acme-challenge.abc.def","rrset_type":"TXT"}`)). + Route("PUT /domains/example.com/records/_acme-challenge.abc.def/TXT", + servermock.RawStringResponse(`{"message": "Zone Record Created"}`), + servermock.CheckRequestJSONBody(`{"rrset_ttl":300,"rrset_values":["ezRpBPY8wH8djMLYjX2uCKPwiKDkFZ1SFMJ6ZXGlHrQ"]}`)). + Route("DELETE /domains/example.com/records/_acme-challenge.abc.def/TXT", nil). + Build(t) fakeKeyAuth := "XXXX" - regexpToken := regexp.MustCompile(`"rrset_values":\[".+"\]`) - - // start fake RPC server - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/domains/example.com/records/_acme-challenge.abc.def/TXT", func(rw http.ResponseWriter, req *http.Request) { - log.Infof("request: %s %s", req.Method, req.URL) - - if req.Header.Get("Authorization") != "Bearer 123412341234123412341234" { - http.Error(rw, `{"message": "missing or malformed Authorization"}`, http.StatusUnauthorized) - return - } - - if req.Method == http.MethodPost && req.Header.Get("Content-Type") != "application/json" { - http.Error(rw, `{"message": "invalid content type"}`, http.StatusBadRequest) - return - } - - body, errS := io.ReadAll(req.Body) - if errS != nil { - http.Error(rw, fmt.Sprintf(`{"message": "read body error: %v"}`, errS), http.StatusInternalServerError) - return - } - - body = regexpToken.ReplaceAllLiteral(body, []byte(`"rrset_values":["TOKEN"]`)) - - responses, ok := serverResponses[req.Method] - if !ok { - http.Error(rw, fmt.Sprintf(`{"message": "Server response for request not found: %#q"}`, string(body)), http.StatusInternalServerError) - return - } - - resp := responses[string(body)] - - _, errS = rw.Write([]byte(resp)) - if errS != nil { - http.Error(rw, fmt.Sprintf(`{"message": "failed to write response: %v"}`, errS), http.StatusInternalServerError) - return - } - }) - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - log.Infof("request: %s %s", req.Method, req.URL) - http.Error(rw, fmt.Sprintf(`{"message": "URL doesn't match: %s"}`, req.URL), http.StatusNotFound) - }) - // define function to override findZoneByFqdn with fakeFindZoneByFqdn := func(fqdn string) (string, error) { return "example.com.", nil } - config := NewDefaultConfig() - config.PersonalAccessToken = "123412341234123412341234" - config.BaseURL = server.URL - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - // override findZoneByFqdn function savedFindZoneByFqdn := provider.findZoneByFqdn + defer func() { provider.findZoneByFqdn = savedFindZoneByFqdn }() + provider.findZoneByFqdn = fakeFindZoneByFqdn // run Present - err = provider.Present("abc.def.example.com", "", fakeKeyAuth) + err := provider.Present("abc.def.example.com", "", fakeKeyAuth) require.NoError(t, err) // run CleanUp diff --git a/providers/dns/gandiv5/internal/client.go b/providers/dns/gandiv5/internal/client.go index 57de9d615..bfb71c9f6 100644 --- a/providers/dns/gandiv5/internal/client.go +++ b/providers/dns/gandiv5/internal/client.go @@ -15,10 +15,7 @@ import ( ) // defaultBaseURL endpoint is the Gandi API endpoint used by Present and CleanUp. -const defaultBaseURL = "https://dns.api.gandi.net/api/v5" - -// APIKeyHeader API key header. -const APIKeyHeader = "X-Api-Key" +const defaultBaseURL = "https://api.gandi.net/v5/livedns" // Related to Personal Access Token. const authorizationHeader = "Authorization" @@ -78,6 +75,7 @@ func (c *Client) getTXTRecord(ctx context.Context, domain, name string) (*Record } txtRecord := &Record{} + err = c.do(req, txtRecord) if err != nil { return nil, fmt.Errorf("unable to get TXT records for domain %s and name %s: %w", domain, name, err) @@ -95,6 +93,7 @@ func (c *Client) addTXTRecord(ctx context.Context, domain, name string, newRecor } message := apiResponse{} + err = c.do(req, &message) if err != nil { return fmt.Errorf("unable to create TXT record for domain %s and name %s: %w", domain, name, err) @@ -116,6 +115,7 @@ func (c *Client) DeleteTXTRecord(ctx context.Context, domain, name string) error } message := apiResponse{} + err = c.do(req, &message) if err != nil { return fmt.Errorf("unable to delete TXT record for domain %s and name %s: %w", domain, name, err) @@ -130,7 +130,7 @@ func (c *Client) DeleteTXTRecord(ctx context.Context, domain, name string) error func (c *Client) do(req *http.Request, result any) error { if c.apiKey != "" { - req.Header.Set(APIKeyHeader, c.apiKey) + req.Header.Set(authorizationHeader, "Apikey "+c.apiKey) } if c.pat != "" { @@ -208,6 +208,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) response := apiResponse{} + err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/gandiv5/internal/client_test.go b/providers/dns/gandiv5/internal/client_test.go new file mode 100644 index 000000000..6a4158dcb --- /dev/null +++ b/providers/dns/gandiv5/internal/client_test.go @@ -0,0 +1,54 @@ +package internal + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +func mockBuilder(apiKey, pat string) *servermock.Builder[*Client] { + checkHeaders := servermock.CheckHeader().WithJSONHeaders() + + if apiKey != "" { + checkHeaders = checkHeaders.WithAuthorization("Apikey secret-apikey") + } else { + checkHeaders = checkHeaders.WithAuthorization("Bearer secret-pat") + } + + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(apiKey, pat) + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + checkHeaders, + ) +} + +func TestClient_AddTXTRecord(t *testing.T) { + client := mockBuilder("secret-apikey", ""). + Route("GET /domains/example.com/records/foo/TXT", + servermock.ResponseFromFixture("add_txt_record_get.json")). + Route("PUT /domains/example.com/records/foo/TXT", + servermock.ResponseFromFixture("api_response.json"), + servermock.CheckRequestJSONBody(`{"rrset_ttl":120,"rrset_values":["content","value1"]}`)). + Build(t) + + err := client.AddTXTRecord(t.Context(), "example.com", "foo", "content", 120) + require.NoError(t, err) +} + +func TestClient_DeleteTXTRecord(t *testing.T) { + client := mockBuilder("", "secret-pat"). + Route("DELETE /domains/example.com/records/foo/TXT", + servermock.ResponseFromFixture("api_response.json")). + Build(t) + + err := client.DeleteTXTRecord(t.Context(), "example.com", "foo") + require.NoError(t, err) +} diff --git a/providers/dns/gandiv5/internal/fixtures/add_txt_record_get.json b/providers/dns/gandiv5/internal/fixtures/add_txt_record_get.json new file mode 100644 index 000000000..fead6ab0a --- /dev/null +++ b/providers/dns/gandiv5/internal/fixtures/add_txt_record_get.json @@ -0,0 +1,8 @@ +{ + "rrset_ttl": 120, + "rrset_values": [ + "value1" + ], + "rrset_name": "foo", + "rrset_type": "TXT" +} diff --git a/providers/dns/gandiv5/internal/fixtures/api_response.json b/providers/dns/gandiv5/internal/fixtures/api_response.json new file mode 100644 index 000000000..47f4352ff --- /dev/null +++ b/providers/dns/gandiv5/internal/fixtures/api_response.json @@ -0,0 +1,4 @@ +{ + "message": "test", + "uuid": "123456789" +} diff --git a/providers/dns/gcloud/gcloud.toml b/providers/dns/gcloud/gcloud.toml index ed12a75dc..63d22bed3 100644 --- a/providers/dns/gcloud/gcloud.toml +++ b/providers/dns/gcloud/gcloud.toml @@ -5,9 +5,29 @@ Code = "gcloud" Since = "v0.3.0" Example = ''' +# Using a service account file GCE_PROJECT="gc-project-id" \ GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file.json" \ -lego --email you@email.com --dns gcloud -d '*.example.com' -d example.com run +lego --dns gcloud -d '*.example.com' -d example.com run + +# Using default credentials with impersonation +GCE_PROJECT="gc-project-id" \ +GCE_IMPERSONATE_SERVICE_ACCOUNT="target-sa@gc-project-id.iam.gserviceaccount.com" \ +lego --dns gcloud -d '*.example.com' -d example.com run + +# Using service account key with impersonation +GCE_PROJECT="gc-project-id" \ +GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file.json" \ +GCE_IMPERSONATE_SERVICE_ACCOUNT="target-sa@gc-project-id.iam.gserviceaccount.com" \ +lego --dns gcloud -d '*.example.com' -d example.com run +''' + +Additional = ''' +Supports service account impersonation to access Google Cloud DNS resources across different projects or with restricted permissions. + +When using impersonation, the source service account must have: +1. The "Service Account Token Creator" role on the source service account +2. The "https://www.googleapis.com/auth/cloud-platform" scope ''' [Configuration] @@ -19,9 +39,10 @@ lego --email you@email.com --dns gcloud -d '*.example.com' -d example.com run [Configuration.Additional] GCE_ALLOW_PRIVATE_ZONE = "Allows requested domain to be in private DNS zone, works only with a private ACME server (by default: false)" GCE_ZONE_ID = "Allows to skip the automatic detection of the zone" - GCE_POLLING_INTERVAL = "Time between DNS propagation check" - GCE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - GCE_TTL = "The TTL of the TXT record used for the DNS challenge" + GCE_IMPERSONATE_SERVICE_ACCOUNT = "Service account email to impersonate" + GCE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" + GCE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 180)" + GCE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" [Links] API = "https://cloud.google.com/dns/api/v1/" diff --git a/providers/dns/gcloud/googlecloud.go b/providers/dns/gcloud/googlecloud.go index bec094d19..61e8ee66f 100644 --- a/providers/dns/gcloud/googlecloud.go +++ b/providers/dns/gcloud/googlecloud.go @@ -2,6 +2,7 @@ package gcloud import ( + "context" "encoding/json" "errors" "fmt" @@ -11,64 +12,72 @@ import ( "time" "cloud.google.com/go/compute/metadata" + "github.com/cenkalti/backoff/v5" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/wait" - "golang.org/x/net/context" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/miekg/dns" + "golang.org/x/oauth2" "golang.org/x/oauth2/google" - "google.golang.org/api/dns/v1" + gdns "google.golang.org/api/dns/v1" "google.golang.org/api/googleapi" + "google.golang.org/api/impersonate" "google.golang.org/api/option" ) -const ( - changeStatusDone = "done" -) - // Environment variables names. const ( envNamespace = "GCE_" - EnvServiceAccount = envNamespace + "SERVICE_ACCOUNT" - EnvProject = envNamespace + "PROJECT" - EnvZoneID = envNamespace + "ZONE_ID" - EnvAllowPrivateZone = envNamespace + "ALLOW_PRIVATE_ZONE" - EnvDebug = envNamespace + "DEBUG" + EnvServiceAccount = envNamespace + "SERVICE_ACCOUNT" + EnvProject = envNamespace + "PROJECT" + EnvZoneID = envNamespace + "ZONE_ID" + EnvAllowPrivateZone = envNamespace + "ALLOW_PRIVATE_ZONE" + EnvDebug = envNamespace + "DEBUG" + EnvImpersonateServiceAccount = envNamespace + "IMPERSONATE_SERVICE_ACCOUNT" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +const changeStatusDone = "done" + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { - Debug bool - Project string - ZoneID string - AllowPrivateZone bool - PropagationTimeout time.Duration - PollingInterval time.Duration - TTL int - HTTPClient *http.Client + Debug bool + Project string + ZoneID string + AllowPrivateZone bool + ImpersonateServiceAccount string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - Debug: env.GetOrDefaultBool(EnvDebug, false), - ZoneID: env.GetOrDefaultString(EnvZoneID, ""), - AllowPrivateZone: env.GetOrDefaultBool(EnvAllowPrivateZone, false), - TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 180*time.Second), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), + Debug: env.GetOrDefaultBool(EnvDebug, false), + ZoneID: env.GetOrDefaultString(EnvZoneID, ""), + AllowPrivateZone: env.GetOrDefaultBool(EnvAllowPrivateZone, false), + ImpersonateServiceAccount: env.GetOrDefaultString(EnvImpersonateServiceAccount, ""), + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 180*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config - client *dns.Service + client *gdns.Service } // NewDNSProvider returns a DNSProvider instance configured for Google Cloud DNS. @@ -84,6 +93,7 @@ func NewDNSProvider() (*DNSProvider, error) { // Use default credentials. project := env.GetOrDefaultString(EnvProject, autodetectProjectID(context.Background())) + return NewDNSProviderCredentials(project) } @@ -94,14 +104,15 @@ func NewDNSProviderCredentials(project string) (*DNSProvider, error) { return nil, errors.New("googlecloud: project name missing") } - client, err := google.DefaultClient(context.Background(), dns.NdevClouddnsReadwriteScope) - if err != nil { - return nil, fmt.Errorf("googlecloud: unable to get Google Cloud client: %w", err) - } - config := NewDefaultConfig() config.Project = project - config.HTTPClient = client + + var err error + + config.HTTPClient, err = newClientFromCredentials(context.Background(), config) + if err != nil { + return nil, fmt.Errorf("googlecloud: %w", err) + } return NewDNSProviderConfig(config) } @@ -121,22 +132,24 @@ func NewDNSProviderServiceAccountKey(saKey []byte) (*DNSProvider, error) { var datJSON struct { ProjectID string `json:"project_id"` } + err := json.Unmarshal(saKey, &datJSON) if err != nil || datJSON.ProjectID == "" { return nil, errors.New("googlecloud: project ID not found in Google Cloud Service Account file") } + project = datJSON.ProjectID } - conf, err := google.JWTConfigFromJSON(saKey, dns.NdevClouddnsReadwriteScope) - if err != nil { - return nil, fmt.Errorf("googlecloud: unable to acquire config: %w", err) - } - client := conf.Client(context.Background()) - config := NewDefaultConfig() config.Project = project - config.HTTPClient = client + + var err error + + config.HTTPClient, err = newClientFromServiceAccountKey(context.Background(), config, saKey) + if err != nil { + return nil, fmt.Errorf("googlecloud: %w", err) + } return NewDNSProviderConfig(config) } @@ -161,11 +174,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("googlecloud: the configuration of the DNS provider is nil") } + if config.HTTPClient == nil { return nil, errors.New("googlecloud: unable to create Google Cloud DNS service: client is nil") } - svc, err := dns.NewService(context.Background(), option.WithHTTPClient(config.HTTPClient)) + svc, err := gdns.NewService(context.Background(), option.WithHTTPClient(clientdebug.Wrap(config.HTTPClient))) if err != nil { return nil, fmt.Errorf("googlecloud: unable to create Google Cloud DNS service: %w", err) } @@ -175,6 +189,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getHostedZone(info.EffectiveFQDN) @@ -190,6 +206,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { for _, rrSet := range existingRrSet { var rrd []string + for _, rr := range rrSet.Rrdatas { data := mustUnquote(rr) rrd = append(rrd, data) @@ -199,17 +216,18 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return nil } } + rrSet.Rrdatas = rrd } // Attempt to delete the existing records before adding the new one. if len(existingRrSet) > 0 { - if err = d.applyChanges(zone, &dns.Change{Deletions: existingRrSet}); err != nil { + if err = d.applyChanges(ctx, zone, &gdns.Change{Deletions: existingRrSet}); err != nil { return fmt.Errorf("googlecloud: %w", err) } } - rec := &dns.ResourceRecordSet{ + rec := &gdns.ResourceRecordSet{ Name: info.EffectiveFQDN, Rrdatas: []string{info.Value}, Ttl: int64(d.config.TTL), @@ -225,18 +243,18 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { } } - change := &dns.Change{ - Additions: []*dns.ResourceRecordSet{rec}, + change := &gdns.Change{ + Additions: []*gdns.ResourceRecordSet{rec}, } - if err = d.applyChanges(zone, change); err != nil { + if err = d.applyChanges(ctx, zone, change); err != nil { return fmt.Errorf("googlecloud: %w", err) } return nil } -func (d *DNSProvider) applyChanges(zone string, change *dns.Change) error { +func (d *DNSProvider) applyChanges(ctx context.Context, zone string, change *gdns.Change) error { if d.config.Debug { data, _ := json.Marshal(change) log.Printf("change (Create): %s", string(data)) @@ -250,6 +268,7 @@ func (d *DNSProvider) applyChanges(zone string, change *dns.Change) error { } data, _ := json.Marshal(change) + return fmt.Errorf("failed to perform changes [zone %s, change %s]: %w", zone, string(data), err) } @@ -260,24 +279,28 @@ func (d *DNSProvider) applyChanges(zone string, change *dns.Change) error { chgID := chg.Id // wait for change to be acknowledged - return wait.For("apply change", 30*time.Second, 3*time.Second, func() (bool, error) { - if d.config.Debug { - data, _ := json.Marshal(change) - log.Printf("change (Get): %s", string(data)) - } + return wait.Retry(ctx, + func() error { + if d.config.Debug { + data, _ := json.Marshal(change) + log.Printf("change (Get): %s", string(data)) + } - chg, err = d.client.Changes.Get(d.config.Project, zone, chgID).Do() - if err != nil { - data, _ := json.Marshal(change) - return false, fmt.Errorf("failed to get changes [zone %s, change %s]: %w", zone, string(data), err) - } + chg, err = d.client.Changes.Get(d.config.Project, zone, chgID).Do() + if err != nil { + data, _ := json.Marshal(change) + return fmt.Errorf("failed to get changes [zone %s, change %s]: %w", zone, string(data), err) + } - if chg.Status == changeStatusDone { - return true, nil - } + if chg.Status != changeStatusDone { + return fmt.Errorf("status: %s", chg.Status) + } - return false, fmt.Errorf("status: %s", chg.Status) - }) + return nil + }, + backoff.WithBackOff(backoff.NewConstantBackOff(3*time.Second)), + backoff.WithMaxElapsedTime(30*time.Second), + ) } // CleanUp removes the TXT record matching the specified parameters. @@ -298,10 +321,11 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } - _, err = d.client.Changes.Create(d.config.Project, zone, &dns.Change{Deletions: records}).Do() + _, err = d.client.Changes.Create(d.config.Project, zone, &gdns.Change{Deletions: records}).Do() if err != nil { return fmt.Errorf("googlecloud: %w", err) } + return nil } @@ -347,7 +371,7 @@ func (d *DNSProvider) getHostedZone(domain string) (string, error) { // (gcloud projects get-iam-policy $project_id) (a role with permission dns.managedZones.list) // // If we force a zone list to succeed, we demand more permissions than needed. -func (d *DNSProvider) lookupHostedZoneID(domain string) (string, []*dns.ManagedZone, error) { +func (d *DNSProvider) lookupHostedZoneID(domain string) (string, []*gdns.ManagedZone, error) { // GCE_ZONE_ID override for service accounts to avoid needing zones-list permission if d.config.ZoneID != "" { zone, err := d.client.ManagedZones.Get(d.config.Project, d.config.ZoneID).Do() @@ -355,10 +379,10 @@ func (d *DNSProvider) lookupHostedZoneID(domain string) (string, []*dns.ManagedZ return "", nil, fmt.Errorf("API call ManagedZones.Get for explicit zone ID %q in project %q failed: %w", d.config.ZoneID, d.config.Project, err) } - return zone.DnsName, []*dns.ManagedZone{zone}, nil + return zone.DnsName, []*gdns.ManagedZone{zone}, nil } - authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) + authZone, err := dns01.FindZoneByFqdn(dns.Fqdn(domain)) if err != nil { return "", nil, fmt.Errorf("could not find zone: %w", err) } @@ -374,7 +398,7 @@ func (d *DNSProvider) lookupHostedZoneID(domain string) (string, []*dns.ManagedZ return authZone, zones.ManagedZones, nil } -func (d *DNSProvider) findTxtRecords(zone, fqdn string) ([]*dns.ResourceRecordSet, error) { +func (d *DNSProvider) findTxtRecords(zone, fqdn string) ([]*gdns.ResourceRecordSet, error) { recs, err := d.client.ResourceRecordSets.List(d.config.Project, zone).Name(fqdn).Type("TXT").Do() if err != nil { return nil, err @@ -383,11 +407,60 @@ func (d *DNSProvider) findTxtRecords(zone, fqdn string) ([]*dns.ResourceRecordSe return recs.Rrsets, nil } +func newClientFromCredentials(ctx context.Context, config *Config) (*http.Client, error) { + if config.ImpersonateServiceAccount != "" { + ts, err := google.DefaultTokenSource(ctx, "https://www.googleapis.com/auth/cloud-platform") + if err != nil { + return nil, fmt.Errorf("unable to get default token source: %w", err) + } + + return newImpersonateClient(ctx, config.ImpersonateServiceAccount, ts) + } + + client, err := google.DefaultClient(ctx, gdns.NdevClouddnsReadwriteScope) + if err != nil { + return nil, fmt.Errorf("unable to get Google Cloud client: %w", err) + } + + return client, nil +} + +func newClientFromServiceAccountKey(ctx context.Context, config *Config, saKey []byte) (*http.Client, error) { + if config.ImpersonateServiceAccount != "" { + conf, err := google.JWTConfigFromJSON(saKey, "https://www.googleapis.com/auth/cloud-platform") + if err != nil { + return nil, fmt.Errorf("unable to acquire config: %w", err) + } + + return newImpersonateClient(ctx, config.ImpersonateServiceAccount, conf.TokenSource(ctx)) + } + + conf, err := google.JWTConfigFromJSON(saKey, gdns.NdevClouddnsReadwriteScope) + if err != nil { + return nil, fmt.Errorf("unable to acquire config: %w", err) + } + + return conf.Client(ctx), nil +} + +func newImpersonateClient(ctx context.Context, impersonateServiceAccount string, ts oauth2.TokenSource) (*http.Client, error) { + impersonatedTS, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ + TargetPrincipal: impersonateServiceAccount, + Scopes: []string{gdns.NdevClouddnsReadwriteScope}, + }, option.WithTokenSource(ts)) + if err != nil { + return nil, fmt.Errorf("unable to create impersonated credentials: %w", err) + } + + return oauth2.NewClient(ctx, impersonatedTS), nil +} + func mustUnquote(raw string) string { clean, err := strconv.Unquote(raw) if err != nil { return raw } + return clean } diff --git a/providers/dns/gcloud/googlecloud_test.go b/providers/dns/gcloud/googlecloud_test.go index 453fdd5ed..28b08a2f9 100644 --- a/providers/dns/gcloud/googlecloud_test.go +++ b/providers/dns/gcloud/googlecloud_test.go @@ -1,6 +1,7 @@ package gcloud import ( + "context" "encoding/json" "fmt" "net/http" @@ -10,8 +11,8 @@ import ( "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" - "golang.org/x/net/context" "golang.org/x/oauth2/google" "google.golang.org/api/dns/v1" ) @@ -30,7 +31,8 @@ var envTest = tester.NewEnvTest( envServiceAccountFile, envGoogleApplicationCredentials, envMetadataHost, - EnvServiceAccount). + EnvServiceAccount, + EnvImpersonateServiceAccount). WithDomain(envDomain). WithLiveTestExtra(func() bool { _, err := google.DefaultClient(context.Background(), dns.NdevClouddnsReadwriteScope) @@ -50,7 +52,7 @@ func TestNewDNSProvider(t *testing.T) { envServiceAccountFile: "", // as Travis run on GCE, we have to alter env envGoogleApplicationCredentials: "not-a-secret-file", - envMetadataHost: "http://lego.wtf", // defined here to avoid the client cache. + envMetadataHost: "http://example.com", // defined here to avoid the client cache. }, // the error message varies according to the OS used. expected: "googlecloud: unable to get Google Cloud client: google: error getting credentials using GOOGLE_APPLICATION_CREDENTIALS environment variable: ", @@ -61,7 +63,7 @@ func TestNewDNSProvider(t *testing.T) { EnvProject: "", envServiceAccountFile: "", // as Travis run on GCE, we have to alter env - envMetadataHost: "http://lego.wtf", + envMetadataHost: "http://example.com", }, expected: "googlecloud: project name missing", }, @@ -84,6 +86,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -123,6 +126,7 @@ func TestNewDNSProviderConfig(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() config := NewDefaultConfig() @@ -143,245 +147,162 @@ func TestNewDNSProviderConfig(t *testing.T) { } func TestPresentNoExistingRR(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + provider := mockBuilder(). + // getHostedZone + Route("GET /dns/v1/projects/manhattan/managedZones", + servermock.JSONEncode(&dns.ManagedZonesListResponse{ + ManagedZones: []*dns.ManagedZone{ + {Name: "test", Visibility: "public"}, + }, + }), + servermock.CheckQueryParameter().Strict(). + With("dnsName", "example.com."). + With("prettyPrint", "false"). + With("alt", "json")). + // findTxtRecords + Route("GET /dns/v1/projects/manhattan/managedZones/test/rrsets", + servermock.JSONEncode(&dns.ResourceRecordSetsListResponse{ + Rrsets: []*dns.ResourceRecordSet{}, + }), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge.example.com."). + With("type", "TXT"). + With("prettyPrint", "false"). + With("alt", "json")). + // applyChanges [Create] + Route("POST /dns/v1/projects/manhattan/managedZones/test/changes", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + var chgReq dns.Change + if err := json.NewDecoder(req.Body).Decode(&chgReq); err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } - // getHostedZone: /manhattan/managedZones?alt=json&dnsName=lego.wtf. - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } + chgResp := chgReq + chgResp.Status = changeStatusDone - mzlrs := &dns.ManagedZonesListResponse{ - ManagedZones: []*dns.ManagedZone{ - {Name: "test", Visibility: "public"}, - }, - } + if err := json.NewEncoder(rw).Encode(chgResp); err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }), + servermock.CheckQueryParameter().Strict(). + With("prettyPrint", "false"). + With("alt", "json")). + Build(t) - err := json.NewEncoder(w).Encode(mzlrs) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + domain := "example.com" - // findTxtRecords: /manhattan/managedZones/test/rrsets?alt=json&name=_acme-challenge.lego.wtf.&type=TXT - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones/test/rrsets", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - rrslr := &dns.ResourceRecordSetsListResponse{ - Rrsets: []*dns.ResourceRecordSet{}, - } - - err := json.NewEncoder(w).Encode(rrslr) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - // applyChanges [Create]: /manhattan/managedZones/test/changes?alt=json - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones/test/changes", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - var chgReq dns.Change - if err := json.NewDecoder(r.Body).Decode(&chgReq); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - chgResp := chgReq - chgResp.Status = changeStatusDone - - if err := json.NewEncoder(w).Encode(chgResp); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - config := NewDefaultConfig() - config.HTTPClient = &http.Client{Timeout: 10 * time.Second} - config.Project = "manhattan" - - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - p.client.BasePath = server.URL - - domain := "lego.wtf" - - err = p.Present(domain, "", "") + err := provider.Present(domain, "", "") require.NoError(t, err) } func TestPresentWithExistingRR(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - // getHostedZone: /manhattan/managedZones?alt=json&dnsName=lego.wtf. - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - mzlrs := &dns.ManagedZonesListResponse{ - ManagedZones: []*dns.ManagedZone{ - {Name: "test", Visibility: "public"}, - }, - } - - err := json.NewEncoder(w).Encode(mzlrs) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - // findTxtRecords: /manhattan/managedZones/test/rrsets?alt=json&name=_acme-challenge.lego.wtf.&type=TXT - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones/test/rrsets", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - rrslr := &dns.ResourceRecordSetsListResponse{ - Rrsets: []*dns.ResourceRecordSet{{ - Name: "_acme-challenge.lego.wtf.", - Rrdatas: []string{`"X7DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"huji"`}, - Ttl: 120, - Type: "TXT", - }}, - } - - err := json.NewEncoder(w).Encode(rrslr) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - // applyChanges [Create]: /manhattan/managedZones/test/changes?alt=json - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones/test/changes", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - var chgReq dns.Change - if err := json.NewDecoder(r.Body).Decode(&chgReq); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - if len(chgReq.Additions) > 0 { - sort.Strings(chgReq.Additions[0].Rrdatas) - } - - var prevVal string - for _, addition := range chgReq.Additions { - for _, value := range addition.Rrdatas { - if prevVal == value { - http.Error(w, fmt.Sprintf("The resource %s already exists", value), http.StatusConflict) + provider := mockBuilder(). + // getHostedZone + Route("GET /dns/v1/projects/manhattan/managedZones", + servermock.JSONEncode(&dns.ManagedZonesListResponse{ + ManagedZones: []*dns.ManagedZone{ + {Name: "test", Visibility: "public"}, + }, + }), + servermock.CheckQueryParameter().Strict(). + With("dnsName", "example.com."). + With("prettyPrint", "false"). + With("alt", "json")). + // findTxtRecords + Route("GET /dns/v1/projects/manhattan/managedZones/test/rrsets", + servermock.JSONEncode(&dns.ResourceRecordSetsListResponse{ + Rrsets: []*dns.ResourceRecordSet{{ + Name: "_acme-challenge.example.com.", + Rrdatas: []string{`"X7DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"huji"`}, + Ttl: 120, + Type: "TXT", + }}, + }), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge.example.com."). + With("type", "TXT"). + With("prettyPrint", "false"). + With("alt", "json")). + // applyChanges [Create] + Route("POST /dns/v1/projects/manhattan/managedZones/test/changes", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + var chgReq dns.Change + if err := json.NewDecoder(req.Body).Decode(&chgReq); err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) return } - prevVal = value - } - } - chgResp := chgReq - chgResp.Status = changeStatusDone + if len(chgReq.Additions) > 0 { + sort.Strings(chgReq.Additions[0].Rrdatas) + } - if err := json.NewEncoder(w).Encode(chgResp); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + var prevVal string - config := NewDefaultConfig() - config.HTTPClient = &http.Client{Timeout: 10 * time.Second} - config.Project = "manhattan" + for _, addition := range chgReq.Additions { + for _, value := range addition.Rrdatas { + if prevVal == value { + http.Error(rw, fmt.Sprintf("The resource %s already exists", value), http.StatusConflict) + return + } - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) + prevVal = value + } + } - p.client.BasePath = server.URL + chgResp := chgReq + chgResp.Status = changeStatusDone - domain := "lego.wtf" + if err := json.NewEncoder(rw).Encode(chgResp); err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }), + servermock.CheckQueryParameter().Strict(). + With("prettyPrint", "false"). + With("alt", "json")). + Build(t) - err = p.Present(domain, "", "") + domain := "example.com" + + err := provider.Present(domain, "", "") require.NoError(t, err) } func TestPresentSkipExistingRR(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + provider := mockBuilder(). + // getHostedZone + Route("GET /dns/v1/projects/manhattan/managedZones", + servermock.JSONEncode(&dns.ManagedZonesListResponse{ + ManagedZones: []*dns.ManagedZone{ + {Name: "test", Visibility: "public"}, + }, + }), + servermock.CheckQueryParameter().Strict(). + With("dnsName", "example.com."). + With("prettyPrint", "false"). + With("alt", "json")). + // findTxtRecords + Route("GET /dns/v1/projects/manhattan/managedZones/test/rrsets", + servermock.JSONEncode(&dns.ResourceRecordSetsListResponse{ + Rrsets: []*dns.ResourceRecordSet{{ + Name: "_acme-challenge.example.com.", + Rrdatas: []string{`"47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"X7DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"huji"`}, + Ttl: 120, + Type: "TXT", + }}, + }), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge.example.com."). + With("type", "TXT"). + With("prettyPrint", "false"). + With("alt", "json")). + Build(t) - // getHostedZone: /manhattan/managedZones?alt=json&dnsName=lego.wtf. - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } + domain := "example.com" - mzlrs := &dns.ManagedZonesListResponse{ - ManagedZones: []*dns.ManagedZone{ - {Name: "test", Visibility: "public"}, - }, - } - - err := json.NewEncoder(w).Encode(mzlrs) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - // findTxtRecords: /manhattan/managedZones/test/rrsets?alt=json&name=_acme-challenge.lego.wtf.&type=TXT - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones/test/rrsets", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - rrslr := &dns.ResourceRecordSetsListResponse{ - Rrsets: []*dns.ResourceRecordSet{{ - Name: "_acme-challenge.lego.wtf.", - Rrdatas: []string{`"47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"X7DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"huji"`}, - Ttl: 120, - Type: "TXT", - }}, - } - - err := json.NewEncoder(w).Encode(rrslr) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - config := NewDefaultConfig() - config.HTTPClient = &http.Client{Timeout: 10 * time.Second} - config.Project = "manhattan" - - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - p.client.BasePath = server.URL - - domain := "lego.wtf" - - err = p.Present(domain, "", "") + err := provider.Present(domain, "", "") require.NoError(t, err) } @@ -431,3 +352,20 @@ func TestLiveCleanUp(t *testing.T) { err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.HTTPClient = server.Client() + config.Project = "manhattan" + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BasePath = server.URL + + return p, err + }) +} diff --git a/providers/dns/gcore/gcore.go b/providers/dns/gcore/gcore.go index fe427647f..9b98f28d4 100644 --- a/providers/dns/gcore/gcore.go +++ b/providers/dns/gcore/gcore.go @@ -1,21 +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" -) - -const ( - defaultPropagationTimeout = 360 * time.Second - defaultPollingInterval = 20 * time.Second + "github.com/go-acme/lego/v4/providers/dns/internal/gcore" ) // Environment variables names. @@ -30,21 +25,17 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +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), }, @@ -53,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. @@ -76,91 +66,36 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("gcore: the configuration of the DNS provider is nil") } - if config.APIToken == "" { - return nil, errors.New("gcore: incomplete credentials provided") + provider, err := gcore.NewDNSProviderConfig(config, "") + if err != nil { + return nil, fmt.Errorf("gcore: %w", err) } - client := internal.NewClient(config.APIToken) - - if config.HTTPClient != nil { - client.HTTPClient = config.HTTPClient - } - - return &DNSProvider{ - config: config, - client: client, - }, nil + return &DNSProvider{prv: provider}, nil } -// Present creates a TXT record to fulfill the dns-01 challenge. -func (d *DNSProvider) Present(domain, _, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - ctx := context.Background() - - zone, err := d.guessZone(ctx, info.EffectiveFQDN) +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("gcore: %w", err) } - err = d.client.AddRRSet(ctx, zone, dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL) - if err != nil { - return fmt.Errorf("gcore: add txt record: %w", err) - } - return nil } -// CleanUp removes the record matching the specified parameters. -func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - ctx := context.Background() - - zone, err := d.guessZone(ctx, info.EffectiveFQDN) +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("gcore: %w", err) } - err = d.client.DeleteRRSet(ctx, zone, dns01.UnFqdn(info.EffectiveFQDN)) - if err != nil { - return fmt.Errorf("gcore: remove txt record: %w", err) - } - return nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.config.PropagationTimeout, d.config.PollingInterval -} - -func (d *DNSProvider) guessZone(ctx context.Context, fqdn string) (string, error) { - var lastErr error - - for _, zone := range extractAllZones(fqdn) { - dnsZone, err := d.client.GetZone(ctx, zone) - if err == nil { - return dnsZone.Name, nil - } - - lastErr = err - } - - return "", fmt.Errorf("zone %q not found: %w", fqdn, lastErr) -} - -func extractAllZones(fqdn string) []string { - parts := strings.Split(dns01.UnFqdn(fqdn), ".") - if len(parts) < 3 { - return nil - } - - var zones []string - for i := 1; i < len(parts)-1; i++ { - zones = append(zones, strings.Join(parts[i:], ".")) - } - - return zones + return d.prv.Timeout() } diff --git a/providers/dns/gcore/gcore.toml b/providers/dns/gcore/gcore.toml index bd514ac78..983c35f8a 100644 --- a/providers/dns/gcore/gcore.toml +++ b/providers/dns/gcore/gcore.toml @@ -6,17 +6,17 @@ Since = "v4.5.0" Example = ''' GCORE_PERMANENT_API_TOKEN=xxxxx \ -lego --email you@example.com --dns gcore -d '*.example.com' -d example.com run +lego --dns gcore -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] GCORE_PERMANENT_API_TOKEN = "Permanent API token (https://gcore.com/blog/permanent-api-token-explained/)" [Configuration.Additional] - GCORE_POLLING_INTERVAL = "Time between DNS propagation check" - GCORE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - GCORE_TTL = "The TTL of the TXT record used for the DNS challenge" - GCORE_HTTP_TIMEOUT = "API request timeout" + GCORE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)" + GCORE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 360)" + GCORE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + GCORE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://api.gcore.com/docs/dns#tag/zones" diff --git a/providers/dns/gcore/gcore_test.go b/providers/dns/gcore/gcore_test.go index a5eddee7c..6f8e38c12 100644 --- a/providers/dns/gcore/gcore_test.go +++ b/providers/dns/gcore/gcore_test.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/go-acme/lego/v4/platform/tester" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -34,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -43,8 +43,7 @@ func TestNewDNSProvider(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - require.NotNil(t, p.config) - require.NotNil(t, p.client) + require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -78,8 +77,7 @@ func TestNewDNSProviderConfig(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - require.NotNil(t, p.config) - require.NotNil(t, p.client) + require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -93,6 +91,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -106,36 +105,10 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } - -func Test_extractAllZones(t *testing.T) { - testCases := []struct { - desc string - fqdn string - expected []string - }{ - { - desc: "success", - fqdn: "_acme-challenge.my.test.domain.com.", - expected: []string{"my.test.domain.com", "test.domain.com", "domain.com"}, - }, - { - desc: "empty", - fqdn: "_acme-challenge.com.", - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - - got := extractAllZones(test.fqdn) - assert.Equal(t, test.expected, got) - }) - } -} diff --git a/providers/dns/gcore/internal/client_test.go b/providers/dns/gcore/internal/client_test.go deleted file mode 100644 index f414b33e1..000000000 --- a/providers/dns/gcore/internal/client_test.go +++ /dev/null @@ -1,256 +0,0 @@ -package internal - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "reflect" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const ( - testToken = "test" - testRecordContent = "acme" - testRecordContent2 = "foo" - testTTL = 10 -) - -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(testToken) - client.baseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func TestClient_GetZone(t *testing.T) { - client, mux := setupTest(t) - - expected := Zone{Name: "example.com"} - - mux.Handle("/v2/zones/example.com", validationHandler{ - method: http.MethodGet, - next: handleJSONResponse(expected), - }) - - zone, err := client.GetZone(context.Background(), "example.com") - require.NoError(t, err) - - assert.Equal(t, expected, zone) -} - -func TestClient_GetZone_error(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/v2/zones/example.com", validationHandler{ - method: http.MethodGet, - next: handleAPIError(), - }) - - _, err := client.GetZone(context.Background(), "example.com") - require.Error(t, err) -} - -func TestClient_GetRRSet(t *testing.T) { - client, mux := setupTest(t) - - expected := RRSet{ - TTL: testTTL, - Records: []Records{ - {Content: []string{testRecordContent}}, - }, - } - - mux.Handle("/v2/zones/example.com/foo.example.com/TXT", validationHandler{ - method: http.MethodGet, - next: handleJSONResponse(expected), - }) - - rrSet, err := client.GetRRSet(context.Background(), "example.com", "foo.example.com") - require.NoError(t, err) - - assert.Equal(t, expected, rrSet) -} - -func TestClient_GetRRSet_error(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/v2/zones/example.com/foo.example.com/TXT", validationHandler{ - method: http.MethodGet, - next: handleAPIError(), - }) - - _, err := client.GetRRSet(context.Background(), "example.com", "foo.example.com") - require.Error(t, err) -} - -func TestClient_DeleteRRSet(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/v2/zones/test.example.com/my.test.example.com/"+txtRecordType, - validationHandler{method: http.MethodDelete}) - - err := client.DeleteRRSet(context.Background(), "test.example.com", "my.test.example.com.") - require.NoError(t, err) -} - -func TestClient_DeleteRRSet_error(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/v2/zones/test.example.com/my.test.example.com/"+txtRecordType, validationHandler{ - method: http.MethodDelete, - next: handleAPIError(), - }) - - err := client.DeleteRRSet(context.Background(), "test.example.com", "my.test.example.com.") - require.NoError(t, err) -} - -func TestClient_AddRRSet(t *testing.T) { - testCases := []struct { - desc string - zone string - recordName string - value string - handledDomain string - handlers map[string]http.Handler - wantErr bool - }{ - { - desc: "success add", - zone: "test.example.com", - recordName: "my.test.example.com", - value: testRecordContent, - handlers: map[string]http.Handler{ - // createRRSet - "/v2/zones/test.example.com/my.test.example.com/" + txtRecordType: validationHandler{ - method: http.MethodPost, - next: handleAddRRSet([]Records{{Content: []string{testRecordContent}}}), - }, - }, - }, - { - desc: "success update", - zone: "test.example.com", - recordName: "my.test.example.com", - value: testRecordContent, - handlers: map[string]http.Handler{ - "/v2/zones/test.example.com/my.test.example.com/" + txtRecordType: http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - switch req.Method { - case http.MethodGet: // GetRRSet - data := RRSet{ - TTL: testTTL, - Records: []Records{{Content: []string{testRecordContent2}}}, - } - handleJSONResponse(data).ServeHTTP(rw, req) - case http.MethodPut: // updateRRSet - expected := []Records{ - {Content: []string{testRecordContent}}, - {Content: []string{testRecordContent2}}, - } - handleAddRRSet(expected).ServeHTTP(rw, req) - default: - http.Error(rw, "wrong method", http.StatusMethodNotAllowed) - } - }), - }, - }, - { - desc: "not in the zone", - zone: "test.example.com", - recordName: "notfound.example.com", - value: testRecordContent, - wantErr: true, - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - cl, mux := setupTest(t) - - for pattern, handler := range test.handlers { - mux.Handle(pattern, handler) - } - - err := cl.AddRRSet(context.Background(), test.zone, test.recordName, test.value, testTTL) - if test.wantErr { - require.Error(t, err) - return - } - - require.NoError(t, err) - }) - } -} - -type validationHandler struct { - method string - next http.Handler -} - -func (v validationHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - if req.Header.Get(authorizationHeader) != fmt.Sprintf("%s %s", tokenTypeHeader, testToken) { - rw.WriteHeader(http.StatusForbidden) - _ = json.NewEncoder(rw).Encode(APIError{Message: "token up for parsing was not passed through the context"}) - return - } - - if req.Method != v.method { - http.Error(rw, "wrong method", http.StatusMethodNotAllowed) - return - } - - if v.next != nil { - v.next.ServeHTTP(rw, req) - } -} - -func handleAPIError() http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - rw.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(rw).Encode(APIError{Message: "oops"}) - } -} - -func handleJSONResponse(data interface{}) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - err := json.NewEncoder(rw).Encode(data) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} - -func handleAddRRSet(expected []Records) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - body := RRSet{} - - err := json.NewDecoder(req.Body).Decode(&body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if body.TTL != testTTL { - http.Error(rw, "wrong ttl", http.StatusInternalServerError) - return - } - - if !reflect.DeepEqual(body.Records, expected) { - http.Error(rw, "wrong resource records", http.StatusInternalServerError) - return - } - } -} diff --git a/providers/dns/gigahostno/gigahostno.go b/providers/dns/gigahostno/gigahostno.go new file mode 100644 index 000000000..b9ed23f3f --- /dev/null +++ b/providers/dns/gigahostno/gigahostno.go @@ -0,0 +1,233 @@ +// Package gigahostno implements a DNS provider for solving the DNS-01 challenge using Gigahost.no. +package gigahostno + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/gigahostno/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "GIGAHOSTNO_" + + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "PASSWORD" + EnvSecret = envNamespace + "SECRET" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Username string + Password string + Secret string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + + identifier *internal.Identifier + client *internal.Client + + tokenMu sync.Mutex + token *internal.Token +} + +// NewDNSProvider returns a DNSProvider instance configured for Gigahost. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvPassword) + if err != nil { + return nil, fmt.Errorf("gigahostno: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + config.Secret = env.GetOrFile(EnvSecret) + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Gigahost. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("gigahostno: the configuration of the DNS provider is nil") + } + + identifier, err := internal.NewIdentifier(config.Username, config.Password, config.Secret) + if err != nil { + return nil, fmt.Errorf("gigahostno: %w", err) + } + + if config.HTTPClient != nil { + identifier.HTTPClient = config.HTTPClient + } + + identifier.HTTPClient = clientdebug.Wrap(identifier.HTTPClient) + + client := internal.NewClient() + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + identifier: identifier, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + err := d.authenticate(ctx) + if err != nil { + return fmt.Errorf("gigahostno: %w", err) + } + + ctx = internal.WithContext(ctx, d.token.Token) + + zone, err := d.findZone(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("gigahostno: %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name) + if err != nil { + return fmt.Errorf("gigahostno: %w", err) + } + + record := internal.Record{ + Name: subDomain, + Type: "TXT", + Value: info.Value, + TTL: d.config.TTL, + } + + err = d.client.CreateNewRecord(ctx, zone.ID, record) + if err != nil { + return fmt.Errorf("gigahostno: create new record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + err := d.authenticate(ctx) + if err != nil { + return fmt.Errorf("gigahostno: %w", err) + } + + ctx = internal.WithContext(ctx, d.token.Token) + + zone, err := d.findZone(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("gigahostno: %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name) + if err != nil { + return fmt.Errorf("gigahostno: %w", err) + } + + records, err := d.client.GetZoneRecords(ctx, zone.ID) + if err != nil { + return fmt.Errorf("gigahostno: get zone records: %w", err) + } + + for _, record := range records { + if record.Type == "TXT" && record.Name == subDomain && record.Value == info.Value { + err := d.client.DeleteRecord(ctx, zone.ID, record.ID, record.Name, record.Type) + if err != nil { + return fmt.Errorf("gigahostno: delete record: %w", err) + } + + break + } + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) authenticate(ctx context.Context) error { + d.tokenMu.Lock() + defer d.tokenMu.Unlock() + + if !d.token.IsExpired() { + return nil + } + + tok, err := d.identifier.Authenticate(ctx) + if err != nil { + return fmt.Errorf("authenticate: %w", err) + } + + d.token = tok + + return nil +} + +func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*internal.Zone, error) { + zones, err := d.client.GetZones(ctx) + if err != nil { + return nil, fmt.Errorf("get zones: %w", err) + } + + for d := range dns01.UnFqdnDomainsSeq(fqdn) { + for _, zone := range zones { + if zone.Name == d && zone.Active == "1" { + return &zone, nil + } + } + } + + return nil, fmt.Errorf("zone not found for %q", fqdn) +} diff --git a/providers/dns/gigahostno/gigahostno.toml b/providers/dns/gigahostno/gigahostno.toml new file mode 100644 index 000000000..b8d3fad2b --- /dev/null +++ b/providers/dns/gigahostno/gigahostno.toml @@ -0,0 +1,25 @@ +Name = "Gigahost.no" +Description = '''''' +URL = "https://gigahost.no/" +Code = "gigahostno" +Since = "v4.29.0" + +Example = ''' +GIGAHOSTNO_USERNAME="xxxxxxxxxxxxxxxxxxxxx" \ +GIGAHOSTNO_PASSWORD="yyyyyyyyyyyyyyyyyyyyy" \ +lego --dns gigahostno -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + GIGAHOSTNO_USERNAME = "Username" + GIGAHOSTNO_PASSWORD = "Password" + [Configuration.Additional] + GIGAHOSTNO_SECRET = "TOTP secret" + GIGAHOSTNO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + GIGAHOSTNO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + GIGAHOSTNO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + GIGAHOSTNO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://gigahost.no/api-dokumentasjon" diff --git a/providers/dns/gigahostno/gigahostno_test.go b/providers/dns/gigahostno/gigahostno_test.go new file mode 100644 index 000000000..7aaac0159 --- /dev/null +++ b/providers/dns/gigahostno/gigahostno_test.go @@ -0,0 +1,277 @@ +package gigahostno + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/go-acme/lego/v4/providers/dns/gigahostno/internal" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvUsername, + EnvPassword, + EnvSecret, +).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "secret", + EnvSecret: "super-secret", + }, + }, + { + desc: "missing GIGAHOSTNO_USERNAME", + envVars: map[string]string{ + EnvPassword: "secret", + }, + expected: "gigahostno: some credentials information are missing: GIGAHOSTNO_USERNAME", + }, + { + desc: "missing GIGAHOSTNO_PASSWORD", + envVars: map[string]string{ + EnvUsername: "user", + }, + expected: "gigahostno: some credentials information are missing: GIGAHOSTNO_PASSWORD", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "gigahostno: some credentials information are missing: GIGAHOSTNO_USERNAME,GIGAHOSTNO_PASSWORD", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + username string + password string + secret string + expected string + }{ + { + desc: "success", + username: "user", + password: "secret", + secret: "super-secret", + }, + { + desc: "missing username", + password: "secret", + expected: "gigahostno: credentials missing", + }, + { + desc: "missing password", + username: "user", + expected: "gigahostno: credentials missing", + }, + { + desc: "missing credentials", + expected: "gigahostno: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Username = test.username + config.Password = test.password + config.Secret = test.secret + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Username = "user" + config.Password = "secret" + config.Secret = "JBSWY3DPEHPK3PXP" + + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + p.identifier.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("POST /authenticate", + servermock.ResponseFromInternal("authenticate.json")). + Route("GET /dns/zones", + servermock.ResponseFromInternal("zones.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer secrettoken")). + Route("POST /dns/zones/123/records", + servermock.ResponseFromInternal("create_record.json"), + servermock.CheckRequestJSONBodyFromInternal("create_record-request.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer secrettoken")). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_Present_token_not_expired(t *testing.T) { + provider := mockBuilder(). + Route("GET /dns/zones", + servermock.ResponseFromInternal("zones.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer secret-token")). + Route("POST /dns/zones/123/records", + servermock.ResponseFromInternal("create_record.json"), + servermock.CheckRequestJSONBodyFromInternal("create_record-request.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer secret-token")). + Build(t) + + provider.token = &internal.Token{ + Token: "secret-token", + TokenExpire: 65322892800, // 2040-01-01 + CustomerID: "123", + } + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("POST /authenticate", + servermock.ResponseFromInternal("authenticate.json")). + Route("GET /dns/zones", + servermock.ResponseFromInternal("zones.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer secrettoken")). + Route("GET /dns/zones/123/records", + servermock.ResponseFromInternal("zone_records.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer secrettoken")). + Route("DELETE /dns/zones/123/records/jkl012", + servermock.ResponseFromInternal("delete_record.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge"). + With("type", "TXT"), + servermock.CheckHeader(). + WithAuthorization("Bearer secrettoken")). + Build(t) + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp_token_not_expired(t *testing.T) { + provider := mockBuilder(). + Route("GET /dns/zones", + servermock.ResponseFromInternal("zones.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer secret-token")). + Route("GET /dns/zones/123/records", + servermock.ResponseFromInternal("zone_records.json"), + servermock.CheckHeader(). + WithAuthorization("Bearer secret-token")). + Route("DELETE /dns/zones/123/records/jkl012", + servermock.ResponseFromInternal("delete_record.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge"). + With("type", "TXT"), + servermock.CheckHeader(). + WithAuthorization("Bearer secret-token")). + Build(t) + + provider.token = &internal.Token{ + Token: "secret-token", + TokenExpire: 65322892800, // 2040-01-01 + CustomerID: "123", + } + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/gigahostno/internal/client.go b/providers/dns/gigahostno/internal/client.go new file mode 100644 index 000000000..cfff3a7b8 --- /dev/null +++ b/providers/dns/gigahostno/internal/client.go @@ -0,0 +1,172 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://api.gigahost.no/api/v0" + +const authorizationHeader = "Authorization" + +// Client the Gigahost.no API client. +type Client struct { + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient() *Client { + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +// GetZones returns all zones. +func (c *Client) GetZones(ctx context.Context) ([]Zone, error) { + endpoint := c.BaseURL.JoinPath("dns", "zones") + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result APIResponse[[]Zone] + + err = c.do(ctx, req, &result) + if err != nil { + return nil, err + } + + return result.Data, nil +} + +// GetZoneRecords returns all records for a zone. +func (c *Client) GetZoneRecords(ctx context.Context, zoneID string) ([]Record, error) { + endpoint := c.BaseURL.JoinPath("dns", "zones", zoneID, "records") + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result APIResponse[[]Record] + + err = c.do(ctx, req, &result) + if err != nil { + return nil, err + } + + return result.Data, nil +} + +// CreateNewRecord creates a new record. +func (c *Client) CreateNewRecord(ctx context.Context, zoneID string, record Record) error { + endpoint := c.BaseURL.JoinPath("dns", "zones", zoneID, "records") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return err + } + + return c.do(ctx, req, nil) +} + +// DeleteRecord deletes a record. +func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID, name, recordType string) error { + endpoint := c.BaseURL.JoinPath("dns", "zones", zoneID, "records", recordID) + + query := endpoint.Query() + query.Set("name", name) + query.Set("type", recordType) + endpoint.RawQuery = query.Encode() + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + return c.do(ctx, req, nil) +} + +func (c *Client) do(ctx context.Context, req *http.Request, result any) error { + useragent.SetHeader(req.Header) + + req.Header.Set(authorizationHeader, "Bearer "+getToken(ctx)) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/gigahostno/internal/client_test.go b/providers/dns/gigahostno/internal/client_test.go new file mode 100644 index 000000000..8d1298947 --- /dev/null +++ b/providers/dns/gigahostno/internal/client_test.go @@ -0,0 +1,149 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient() + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + WithAuthorization("Bearer secret"), + ) +} + +func TestClient_GetZones(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/zones", + servermock.ResponseFromFixture("zones.json")). + Build(t) + + zones, err := client.GetZones(mockContext(t)) + require.NoError(t, err) + + expected := []Zone{ + { + ID: "123", + Name: "example.com", + NameDisplay: "example.com", + Type: "NATIVE", + Active: "1", + }, + { + ID: "226", + Name: "example.org", + NameDisplay: "example.org", + Type: "NATIVE", + Active: "1", + }, + { + ID: "229", + Name: "example.xn--zckzah", + NameDisplay: "example.テスト", + Type: "NATIVE", + Active: "1", + }, + } + + assert.Equal(t, expected, zones) +} + +func TestClient_GetZones_error(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/zones", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + _, err := client.GetZones(mockContext(t)) + require.EqualError(t, err, "401: 401 Unauthorized: 401 Unauthorized") +} + +func TestClient_GetZoneRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/zones/123/records", + servermock.ResponseFromFixture("zone_records.json")). + Build(t) + + zones, err := client.GetZoneRecords(mockContext(t), "123") + require.NoError(t, err) + + expected := []Record{ + { + ID: "abc123", + Name: "@", + Type: "A", + Value: "185.125.168.166", + TTL: 3600, + }, + { + ID: "def456", + Name: "www", + Type: "A", + Value: "185.125.168.166", + TTL: 3600, + }, + { + ID: "ghi789", + Name: "@", + Type: "MX", + Value: "mail.example.no", + TTL: 3600, + }, + { + ID: "jkl012", + Name: "_acme-challenge", + Type: "TXT", + Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 120, + }, + } + + assert.Equal(t, expected, zones) +} + +func TestClient_CreateNewRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/zones/example.com/records", + servermock.ResponseFromFixture("create_record.json"), + servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). + Build(t) + + record := Record{ + Name: "_acme-challenge", + Type: "TXT", + Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 120, + } + + err := client.CreateNewRecord(mockContext(t), "example.com", record) + require.NoError(t, err) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("/dns/zones/123/records/abc123", + servermock.ResponseFromFixture("delete_record.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge"). + With("type", "TXT")). + Build(t) + + err := client.DeleteRecord(mockContext(t), "123", "abc123", "_acme-challenge", "TXT") + require.NoError(t, err) +} diff --git a/providers/dns/gigahostno/internal/fixtures/authenticate-request.json b/providers/dns/gigahostno/internal/fixtures/authenticate-request.json new file mode 100644 index 000000000..c641cd3e5 --- /dev/null +++ b/providers/dns/gigahostno/internal/fixtures/authenticate-request.json @@ -0,0 +1,4 @@ +{ + "username": "user", + "password": "secret" +} diff --git a/providers/dns/gigahostno/internal/fixtures/authenticate.json b/providers/dns/gigahostno/internal/fixtures/authenticate.json new file mode 100644 index 000000000..2c43ccbfe --- /dev/null +++ b/providers/dns/gigahostno/internal/fixtures/authenticate.json @@ -0,0 +1,23 @@ +{ + "meta": { + "status": 200, + "status_message": "200 OK", + "maintenance": false + }, + "data": { + "token": "secrettoken", + "token_expire": 1577836800, + "customer_id": "16030", + "contact_id": "15182", + "customer_name": "Cloudline AS", + "contact_username": "test@example.com", + "contact_access_level": "admin", + "customer_address": "Grønland 14", + "customer_zipcode": "5918", + "customer_city": "Frekhaug", + "customer_province": "Vestland", + "ga_secret": "ga_secret", + "ga_enabled": "1", + "vat": 1 + } +} diff --git a/providers/dns/gigahostno/internal/fixtures/create_record-request.json b/providers/dns/gigahostno/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..f8f0b5b11 --- /dev/null +++ b/providers/dns/gigahostno/internal/fixtures/create_record-request.json @@ -0,0 +1,6 @@ +{ + "record_name": "_acme-challenge", + "record_type": "TXT", + "record_value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "record_ttl": 120 +} diff --git a/providers/dns/gigahostno/internal/fixtures/create_record.json b/providers/dns/gigahostno/internal/fixtures/create_record.json new file mode 100644 index 000000000..9232677d7 --- /dev/null +++ b/providers/dns/gigahostno/internal/fixtures/create_record.json @@ -0,0 +1,7 @@ +{ + "meta": { + "status": 201, + "status_message": "201 Created", + "message": "Record created successfully." + } +} diff --git a/providers/dns/gigahostno/internal/fixtures/delete_record.json b/providers/dns/gigahostno/internal/fixtures/delete_record.json new file mode 100644 index 000000000..9d87f2f42 --- /dev/null +++ b/providers/dns/gigahostno/internal/fixtures/delete_record.json @@ -0,0 +1,7 @@ +{ + "meta": { + "status": 200, + "status_message": "200 OK", + "message": "Record deleted successfully." + } +} diff --git a/providers/dns/gigahostno/internal/fixtures/error.json b/providers/dns/gigahostno/internal/fixtures/error.json new file mode 100644 index 000000000..f2fcfd437 --- /dev/null +++ b/providers/dns/gigahostno/internal/fixtures/error.json @@ -0,0 +1,9 @@ +{ + "meta": { + "status": 401, + "status_message": "401 Unauthorized", + "maintenance": false, + "message": "401 Unauthorized" + }, + "data": [] +} diff --git a/providers/dns/gigahostno/internal/fixtures/zone_records.json b/providers/dns/gigahostno/internal/fixtures/zone_records.json new file mode 100644 index 000000000..e67ff83f4 --- /dev/null +++ b/providers/dns/gigahostno/internal/fixtures/zone_records.json @@ -0,0 +1,39 @@ +{ + "meta": { + "status": 200, + "status_message": "200 OK" + }, + "data": [ + { + "record_id": "abc123", + "record_name": "@", + "record_type": "A", + "record_value": "185.125.168.166", + "record_ttl": 3600, + "record_priority": null + }, + { + "record_id": "def456", + "record_name": "www", + "record_type": "A", + "record_value": "185.125.168.166", + "record_ttl": 3600, + "record_priority": null + }, + { + "record_id": "ghi789", + "record_name": "@", + "record_type": "MX", + "record_value": "mail.example.no", + "record_ttl": 3600, + "record_priority": 10 + }, + { + "record_id": "jkl012", + "record_name": "_acme-challenge", + "record_type": "TXT", + "record_value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "record_ttl": 120 + } + ] +} diff --git a/providers/dns/gigahostno/internal/fixtures/zones.json b/providers/dns/gigahostno/internal/fixtures/zones.json new file mode 100644 index 000000000..d45b0ac49 --- /dev/null +++ b/providers/dns/gigahostno/internal/fixtures/zones.json @@ -0,0 +1,97 @@ +{ + "meta": { + "status": 200, + "status_message": "200 OK", + "maintenance": false, + "message": "200 OK" + }, + "data": [ + { + "zone_id": "123", + "cust_id": "16030", + "order_id": "26117", + "zone_name": "example.com", + "zone_type": "NATIVE", + "zone_active": "1", + "zone_protected": "1", + "zone_is_registered": "1", + "domain_registrar": "norid", + "domain_status": "active", + "domain_registered_date": "2025-11-23 15:17:38", + "domain_expiry_date": "2026-11-23 15:17:38", + "domain_updated_date": "2025-11-23 16:17:38", + "domain_auto_renew": "1", + "domain_epp_id": "LEG2175D-NORID", + "domain_registrant_id": "CA19777O", + "domain_tech_id": "GH295R", + "domain_auth_info": "XXXXXXXXXXXXXXX", + "domain_locked": "0", + "domain_dnssec": "0", + "domain_dnssec_data": null, + "domain_protected_email": null, + "zone_created": "2025-11-23 16:17:29", + "zone_updated": 1700000000, + "external_dns": "0", + "record_count": 4, + "zone_name_display": "example.com" + }, + { + "zone_id": "226", + "cust_id": "16030", + "order_id": "26114", + "zone_name": "example.org", + "zone_type": "NATIVE", + "zone_active": "1", + "zone_protected": "1", + "zone_is_registered": "1", + "domain_registrar": "norid", + "domain_status": "active", + "domain_registered_date": "2025-11-23 14:15:01", + "domain_expiry_date": "2026-11-23 14:15:01", + "domain_updated_date": "2025-11-23 15:15:02", + "domain_auto_renew": "1", + "domain_epp_id": "TEO218D-NORID", + "domain_registrant_id": "CA19774O", + "domain_tech_id": "GH295R", + "domain_auth_info": "XXXXXXXXXXXXXX", + "domain_locked": "0", + "domain_dnssec": "0", + "domain_dnssec_data": null, + "domain_protected_email": null, + "zone_created": "2025-11-23 15:13:27", + "zone_updated": 1700000000, + "external_dns": "0", + "record_count": 5, + "zone_name_display": "example.org" + }, + { + "zone_id": "229", + "cust_id": "16030", + "order_id": "26119", + "zone_name": "example.xn--zckzah", + "zone_type": "NATIVE", + "zone_active": "1", + "zone_protected": "1", + "zone_is_registered": "1", + "domain_registrar": "norid", + "domain_status": "active", + "domain_registered_date": "2014-12-01 12:40:48", + "domain_expiry_date": "2026-12-01 12:40:48", + "domain_updated_date": "2025-11-23 15:37:36", + "domain_auto_renew": "1", + "domain_epp_id": "DIT1003D-NORID", + "domain_registrant_id": "DCA822O", + "domain_tech_id": "GH295R", + "domain_auth_info": "XXXXXXXXXXXXXX", + "domain_locked": "0", + "domain_dnssec": "0", + "domain_dnssec_data": null, + "domain_protected_email": null, + "zone_created": "2025-11-23 16:37:15", + "zone_updated": 1700000000, + "external_dns": "0", + "record_count": 4, + "zone_name_display": "example.\u30C6\u30B9\u30C8" + } + ] +} diff --git a/providers/dns/gigahostno/internal/identity.go b/providers/dns/gigahostno/internal/identity.go new file mode 100644 index 000000000..262dfabdd --- /dev/null +++ b/providers/dns/gigahostno/internal/identity.go @@ -0,0 +1,122 @@ +package internal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" + "github.com/pquerna/otp/totp" +) + +type token string + +const tokenKey token = "token" + +type Identifier struct { + username string + password string + Secret string + + BaseURL *url.URL + HTTPClient *http.Client +} + +func NewIdentifier(username, password, secret string) (*Identifier, error) { + if username == "" || password == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Identifier{ + username: username, + password: password, + Secret: secret, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Identifier) Authenticate(ctx context.Context) (*Token, error) { + endpoint := c.BaseURL.JoinPath("authenticate") + + auth := Auth{Username: c.username, Password: c.password} + + if c.Secret != "" { + tan, err := totp.GenerateCode(c.Secret, time.Now()) + if err != nil { + return nil, fmt.Errorf("generate TOTP: %w", err) + } + + auth.Code, err = strconv.Atoi(tan) + if err != nil { + return nil, fmt.Errorf("parse TOTP: %w", err) + } + } + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, auth) + if err != nil { + return nil, err + } + + var result APIResponse[*Token] + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result.Data, nil +} + +func (c *Identifier) do(req *http.Request, result any) error { + useragent.SetHeader(req.Header) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func WithContext(ctx context.Context, credential string) context.Context { + return context.WithValue(ctx, tokenKey, credential) +} + +func getToken(ctx context.Context) string { + credential, ok := ctx.Value(tokenKey).(string) + if !ok { + return "" + } + + return credential +} diff --git a/providers/dns/gigahostno/internal/identity_test.go b/providers/dns/gigahostno/internal/identity_test.go new file mode 100644 index 000000000..09d72746a --- /dev/null +++ b/providers/dns/gigahostno/internal/identity_test.go @@ -0,0 +1,108 @@ +package internal + +import ( + "context" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupIdentifierClient(server *httptest.Server) (*Identifier, error) { + client, err := NewIdentifier("user", "secret", "") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil +} + +func mockContext(t *testing.T) context.Context { + t.Helper() + + return context.WithValue(t.Context(), tokenKey, "secret") +} + +func TestIdentifier_Authenticate(t *testing.T) { + identifier := servermock.NewBuilder[*Identifier](setupIdentifierClient). + Route("POST /authenticate", + servermock.ResponseFromFixture("authenticate.json"), + servermock.CheckRequestJSONBodyFromFixture("authenticate-request.json")). + Build(t) + + token, err := identifier.Authenticate(context.Background()) + require.NoError(t, err) + + expected := &Token{ + Token: "secrettoken", + TokenExpire: 1577836800, + CustomerID: "16030", + ContactID: "15182", + CustomerName: "Cloudline AS", + ContactUsername: "test@example.com", + ContactAccessLevel: "admin", + CustomerAddress: "Grønland 14", + CustomerZipcode: "5918", + CustomerCity: "Frekhaug", + CustomerProvince: "Vestland", + GASecret: "ga_secret", + GAEnabled: "1", + VAT: 1, + } + + assert.Equal(t, expected, token) +} + +func TestToken_IsExpired(t *testing.T) { + testCases := []struct { + desc string + token *Token + assert assert.BoolAssertionFunc + }{ + { + desc: "nil", + assert: assert.True, + }, + { + desc: "empty", + token: &Token{}, + assert: assert.True, + }, + { + desc: "not expired", + token: &Token{ + TokenExpire: 65322892800, // 2040-01-01 + }, + assert: assert.False, + }, + { + desc: "now", + token: &Token{ + TokenExpire: time.Now().Unix(), + }, + assert: assert.True, + }, + { + desc: "now + 2 minutes", + token: &Token{ + TokenExpire: time.Now().Add(2 * time.Minute).Unix(), + }, + assert: assert.False, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + test.assert(t, test.token.IsExpired()) + }) + } +} diff --git a/providers/dns/gigahostno/internal/types.go b/providers/dns/gigahostno/internal/types.go new file mode 100644 index 000000000..e998dc084 --- /dev/null +++ b/providers/dns/gigahostno/internal/types.go @@ -0,0 +1,73 @@ +package internal + +import ( + "fmt" + "time" +) + +type APIError struct { + Meta MetaData `json:"meta"` +} + +func (a *APIError) Error() string { + return fmt.Sprintf("%d: %s: %s", a.Meta.Status, a.Meta.StatusMessage, a.Meta.Message) +} + +type MetaData struct { + Status int `json:"status,omitempty"` + StatusMessage string `json:"status_message,omitempty"` + Maintenance bool `json:"maintenance"` + Message string `json:"message,omitempty"` +} + +type APIResponse[T any] struct { + Meta MetaData `json:"meta"` + Data T `json:"data,omitempty"` +} + +type Zone struct { + ID string `json:"zone_id,omitempty"` + Name string `json:"zone_name,omitempty"` + NameDisplay string `json:"zone_name_display,omitempty"` + Type string `json:"zone_type,omitempty"` + Active string `json:"zone_active,omitempty"` +} + +type Record struct { + ID string `json:"record_id,omitempty"` + Name string `json:"record_name,omitempty"` + Type string `json:"record_type,omitempty"` + Value string `json:"record_value,omitempty"` + TTL int `json:"record_ttl,omitempty"` +} + +type Auth struct { + Username string `json:"username"` + Password string `json:"password"` + Code int `json:"code,omitempty"` +} + +type Token struct { + Token string `json:"token,omitempty"` + TokenExpire int64 `json:"token_expire,omitempty"` + CustomerID string `json:"customer_id,omitempty"` + ContactID string `json:"contact_id,omitempty"` + CustomerName string `json:"customer_name,omitempty"` + ContactUsername string `json:"contact_username,omitempty"` + ContactAccessLevel string `json:"contact_access_level,omitempty"` + CustomerAddress string `json:"customer_address,omitempty"` + CustomerZipcode string `json:"customer_zipcode,omitempty"` + CustomerCity string `json:"customer_city,omitempty"` + CustomerProvince string `json:"customer_province,omitempty"` + GASecret string `json:"ga_secret,omitempty"` + GAEnabled string `json:"ga_enabled,omitempty"` + VAT int `json:"vat,omitempty"` +} + +func (t *Token) IsExpired() bool { + if t == nil { + return true + } + + return time.Now().UTC().Add(1 * time.Minute).After(time.Unix(t.TokenExpire, 0).UTC()) +} diff --git a/providers/dns/glesys/glesys.go b/providers/dns/glesys/glesys.go index c25b693c5..729756235 100644 --- a/providers/dns/glesys/glesys.go +++ b/providers/dns/glesys/glesys.go @@ -9,13 +9,13 @@ import ( "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/glesys/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) -const minTTL = 60 - // Environment variables names. const ( envNamespace = "GLESYS_" @@ -29,6 +29,10 @@ 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 { APIUser string @@ -96,6 +100,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -130,6 +136,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // save data necessary for CleanUp d.activeRecords[info.EffectiveFQDN] = recordID + return nil } @@ -140,6 +147,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { // acquire lock and retrieve authZone d.inProgressMu.Lock() defer d.inProgressMu.Unlock() + if _, ok := d.activeRecords[info.EffectiveFQDN]; !ok { // if there is no cleanup information then just return return nil diff --git a/providers/dns/glesys/glesys.toml b/providers/dns/glesys/glesys.toml index 146b24517..c0e2613b8 100644 --- a/providers/dns/glesys/glesys.toml +++ b/providers/dns/glesys/glesys.toml @@ -7,7 +7,7 @@ Since = "v0.5.0" Example = ''' GLESYS_API_USER=xxxxx \ GLESYS_API_KEY=yyyyy \ -lego --email you@example.com --dns glesys -d '*.example.com' -d example.com run +lego --dns glesys -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,10 +15,10 @@ lego --email you@example.com --dns glesys -d '*.example.com' -d example.com run GLESYS_API_USER = "API user" GLESYS_API_KEY = "API key" [Configuration.Additional] - GLESYS_POLLING_INTERVAL = "Time between DNS propagation check" - GLESYS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - GLESYS_TTL = "The TTL of the TXT record used for the DNS challenge" - GLESYS_HTTP_TIMEOUT = "API request timeout" + GLESYS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)" + GLESYS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 1200)" + GLESYS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + GLESYS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://github.com/GleSYS/API/wiki/API-Documentation" diff --git a/providers/dns/glesys/glesys_test.go b/providers/dns/glesys/glesys_test.go index d5fdf36da..f2d65e514 100644 --- a/providers/dns/glesys/glesys_test.go +++ b/providers/dns/glesys/glesys_test.go @@ -56,6 +56,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -130,6 +131,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -143,6 +145,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/glesys/internal/client.go b/providers/dns/glesys/internal/client.go index 038c6f0d5..ee6ebc058 100644 --- a/providers/dns/glesys/internal/client.go +++ b/providers/dns/glesys/internal/client.go @@ -24,7 +24,7 @@ type Client struct { HTTPClient *http.Client } -func NewClient(apiUser string, apiKey string) *Client { +func NewClient(apiUser, apiKey string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ @@ -102,6 +102,7 @@ func (c *Client) do(req *http.Request) (*apiResponse, error) { } var response apiResponse + err = json.Unmarshal(raw, &response) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) diff --git a/providers/dns/glesys/internal/client_test.go b/providers/dns/glesys/internal/client_test.go index 7e8ca9724..cd71757ff 100644 --- a/providers/dns/glesys/internal/client_test.go +++ b/providers/dns/glesys/internal/client_test.go @@ -1,79 +1,49 @@ package internal import ( - "context" - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - apiUser, apiKey, ok := req.BasicAuth() - if apiUser != "user" || apiKey != "secret" || !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("user", "secret"), + ) } func TestClient_AddTXTRecord(t *testing.T) { - client := setupTest(t, http.MethodPost, "/domain/addrecord", http.StatusOK, "add-record.json") + client := mockBuilder(). + Route("POST /domain/addrecord", + servermock.ResponseFromFixture("add-record.json"), + servermock.CheckRequestJSONBody(`{"domainname":"example.com","host":"foo","type":"TXT","data":"txt","ttl":120}`)). + Build(t) - recordID, err := client.AddTXTRecord(context.Background(), "example.com", "foo", "txt", 120) + recordID, err := client.AddTXTRecord(t.Context(), "example.com", "foo", "txt", 120) require.NoError(t, err) assert.Equal(t, 123, recordID) } func TestClient_DeleteTXTRecord(t *testing.T) { - client := setupTest(t, http.MethodPost, "/domain/deleterecord", http.StatusOK, "delete-record.json") + client := mockBuilder(). + Route("POST /domain/deleterecord", + servermock.ResponseFromFixture("delete-record.json"), + servermock.CheckRequestJSONBody(`{"recordid":123}`)). + Build(t) - err := client.DeleteTXTRecord(context.Background(), 123) + err := client.DeleteTXTRecord(t.Context(), 123) require.NoError(t, err) } diff --git a/providers/dns/godaddy/godaddy.go b/providers/dns/godaddy/godaddy.go index 7a80ac93f..1603bb57e 100644 --- a/providers/dns/godaddy/godaddy.go +++ b/providers/dns/godaddy/godaddy.go @@ -8,13 +8,13 @@ import ( "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/godaddy/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) -const minTTL = 600 - // Environment variables names. const ( envNamespace = "GODADDY_" @@ -28,6 +28,10 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const minTTL = 600 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -43,7 +47,7 @@ func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, @@ -92,6 +96,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } @@ -125,6 +131,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { } var newRecords []internal.DNSRecord + for _, record := range existingRecords { if record.Data != "" { newRecords = append(newRecords, record) @@ -171,6 +178,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } var recordsToKeep []internal.DNSRecord + for _, record := range existingRecords { if record.Data != info.Value && record.Data != "" { recordsToKeep = append(recordsToKeep, record) diff --git a/providers/dns/godaddy/godaddy.toml b/providers/dns/godaddy/godaddy.toml index aa835d087..b906605b3 100644 --- a/providers/dns/godaddy/godaddy.toml +++ b/providers/dns/godaddy/godaddy.toml @@ -7,7 +7,7 @@ Since = "v0.5.0" Example = ''' GODADDY_API_KEY=xxxxxxxx \ GODADDY_API_SECRET=yyyyyyyy \ -lego --email you@example.com --dns godaddy -d '*.example.com' -d example.com run +lego --dns godaddy -d '*.example.com' -d example.com run ''' Additional = ''' @@ -24,10 +24,10 @@ https://community.letsencrypt.org/t/getting-unauthorized-url-error-while-trying- GODADDY_API_KEY = "API key" GODADDY_API_SECRET = "API secret" [Configuration.Additional] - GODADDY_POLLING_INTERVAL = "Time between DNS propagation check" - GODADDY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - GODADDY_TTL = "The TTL of the TXT record used for the DNS challenge" - GODADDY_HTTP_TIMEOUT = "API request timeout" + GODADDY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + GODADDY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + GODADDY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" + GODADDY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developer.godaddy.com/doc/endpoint/domains" diff --git a/providers/dns/godaddy/godaddy_test.go b/providers/dns/godaddy/godaddy_test.go index 4cb5f2721..38b39672e 100644 --- a/providers/dns/godaddy/godaddy_test.go +++ b/providers/dns/godaddy/godaddy_test.go @@ -56,6 +56,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -126,6 +127,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -139,6 +141,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/godaddy/internal/client.go b/providers/dns/godaddy/internal/client.go index 1902fc1fd..9dd337ddc 100644 --- a/providers/dns/godaddy/internal/client.go +++ b/providers/dns/godaddy/internal/client.go @@ -26,7 +26,7 @@ type Client struct { HTTPClient *http.Client } -func NewClient(apiKey string, apiSecret string) *Client { +func NewClient(apiKey, apiSecret string) *Client { baseURL, _ := url.Parse(DefaultBaseURL) return &Client{ @@ -48,6 +48,7 @@ func (c *Client) GetRecords(ctx context.Context, domainZone, rType, recordName s } var records []DNSRecord + err = c.do(req, &records) if err != nil { return nil, err @@ -141,6 +142,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError + err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/godaddy/internal/client_test.go b/providers/dns/godaddy/internal/client_test.go index 50d193bdb..694a16565 100644 --- a/providers/dns/godaddy/internal/client_test.go +++ b/providers/dns/godaddy/internal/client_test.go @@ -1,40 +1,35 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("key", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("key", "secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("sso-key key:secret")) } func TestClient_GetRecords(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /v1/domains/example.com/records/TXT/", servermock.ResponseFromFixture("getrecords.json")). + Build(t) - mux.HandleFunc("/v1/domains/example.com/records/TXT/", testHandler(http.MethodGet, http.StatusOK, "getrecords.json")) - - records, err := client.GetRecords(context.Background(), "example.com", "TXT", "") + records, err := client.GetRecords(t.Context(), "example.com", "TXT", "") require.NoError(t, err) expected := []DNSRecord{ @@ -50,30 +45,21 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecords_errors(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /v1/domains/example.com/records/TXT/", + servermock.ResponseFromFixture("errors.json").WithStatusCode(http.StatusUnprocessableEntity)). + Build(t) - mux.HandleFunc("/v1/domains/example.com/records/TXT/", testHandler(http.MethodGet, http.StatusUnprocessableEntity, "errors.json")) - - records, err := client.GetRecords(context.Background(), "example.com", "TXT", "") + records, err := client.GetRecords(t.Context(), "example.com", "TXT", "") require.EqualError(t, err, "[status code: 422] INVALID_BODY: Request body doesn't fulfill schema, see details in `fields`") assert.Nil(t, records) } func TestClient_UpdateTxtRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/example.com/records/TXT/lego", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPut { - http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "sso-key key:secret" { - http.Error(rw, fmt.Sprintf("invalid API key or secret: %s", auth), http.StatusUnauthorized) - return - } - }) + client := mockBuilder(). + Route("PUT /v1/domains/example.com/records/TXT/lego", nil, + servermock.CheckRequestJSONBodyFromFixture("update_records-request.json")). + Build(t) records := []DNSRecord{ {Name: "_acme-challenge", Type: "TXT", Data: " ", TTL: 600}, @@ -84,15 +70,16 @@ func TestClient_UpdateTxtRecords(t *testing.T) { {Name: "_acme-challenge.lego", Type: "TXT", Data: "acme", TTL: 600}, } - err := client.UpdateTxtRecords(context.Background(), records, "example.com", "lego") + err := client.UpdateTxtRecords(t.Context(), records, "example.com", "lego") require.NoError(t, err) } func TestClient_UpdateTxtRecords_errors(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/example.com/records/TXT/lego", - testHandler(http.MethodPut, http.StatusUnprocessableEntity, "errors.json")) + client := mockBuilder(). + Route("PUT /v1/domains/example.com/records/TXT/lego", + servermock.ResponseFromFixture("errors.json").WithStatusCode(http.StatusUnprocessableEntity), + servermock.CheckRequestJSONBodyFromFixture("update_records-request.json")). + Build(t) records := []DNSRecord{ {Name: "_acme-challenge", Type: "TXT", Data: " ", TTL: 600}, @@ -103,59 +90,26 @@ func TestClient_UpdateTxtRecords_errors(t *testing.T) { {Name: "_acme-challenge.lego", Type: "TXT", Data: "acme", TTL: 600}, } - err := client.UpdateTxtRecords(context.Background(), records, "example.com", "lego") + err := client.UpdateTxtRecords(t.Context(), records, "example.com", "lego") require.EqualError(t, err, "[status code: 422] INVALID_BODY: Request body doesn't fulfill schema, see details in `fields`") } func TestClient_DeleteTxtRecords(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /v1/domains/example.com/records/TXT/foo", + servermock.Noop().WithStatusCode(http.StatusNoContent)). + Build(t) - mux.HandleFunc("/v1/domains/example.com/records/TXT/foo", testHandler(http.MethodDelete, http.StatusNoContent, "")) - - err := client.DeleteTxtRecords(context.Background(), "example.com", "foo") + err := client.DeleteTxtRecords(t.Context(), "example.com", "foo") require.NoError(t, err) } func TestClient_DeleteTxtRecords_errors(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /v1/domains/example.com/records/TXT/foo", + servermock.ResponseFromFixture("error-extended.json").WithStatusCode(http.StatusConflict)). + Build(t) - mux.HandleFunc("/v1/domains/example.com/records/TXT/foo", testHandler(http.MethodDelete, http.StatusConflict, "error-extended.json")) - - err := client.DeleteTxtRecords(context.Background(), "example.com", "foo") + err := client.DeleteTxtRecords(t.Context(), "example.com", "foo") require.EqualError(t, err, "[status code: 409] ACCESS_DENIED: Authenticated user is not allowed access [test: content (path=/foo) (pathRelated=/bar)]") } - -func testHandler(method string, statusCode int, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "sso-key key:secret" { - http.Error(rw, fmt.Sprintf("invalid API key or secret: %s", auth), http.StatusUnauthorized) - return - } - - rw.WriteHeader(statusCode) - - if statusCode == http.StatusNoContent { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - } -} diff --git a/providers/dns/godaddy/internal/fixtures/update_records-request.json b/providers/dns/godaddy/internal/fixtures/update_records-request.json new file mode 100644 index 000000000..969afb2dc --- /dev/null +++ b/providers/dns/godaddy/internal/fixtures/update_records-request.json @@ -0,0 +1,38 @@ +[ + { + "name": "_acme-challenge", + "type": "TXT", + "data": " ", + "ttl": 600 + }, + { + "name": "_acme-challenge.example", + "type": "TXT", + "data": "6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU", + "ttl": 600 + }, + { + "name": "_acme-challenge.example", + "type": "TXT", + "data": "8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek", + "ttl": 600 + }, + { + "name": "_acme-challenge.lego", + "type": "TXT", + "data": " ", + "ttl": 600 + }, + { + "name": "_acme-challenge.lego", + "type": "TXT", + "data": "0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A", + "ttl": 600 + }, + { + "name": "_acme-challenge.lego", + "type": "TXT", + "data": "acme", + "ttl": 600 + } +] diff --git a/providers/dns/godaddy/internal/types.go b/providers/dns/godaddy/internal/types.go index a97a97896..3bd5c9560 100644 --- a/providers/dns/godaddy/internal/types.go +++ b/providers/dns/godaddy/internal/types.go @@ -1,6 +1,9 @@ package internal -import "fmt" +import ( + "fmt" + "strings" +) // DNSRecord a DNS record. type DNSRecord struct { @@ -23,13 +26,16 @@ type APIError struct { } func (a APIError) Error() string { - msg := fmt.Sprintf("%s: %s", a.Code, a.Message) + msg := new(strings.Builder) + + _, _ = fmt.Fprintf(msg, "%s: %s", a.Code, a.Message) for _, field := range a.Fields { - msg += " " + field.String() + msg.WriteString(" ") + msg.WriteString(field.String()) } - return msg + return msg.String() } type Field struct { diff --git a/providers/dns/googledomains/googledomains.go b/providers/dns/googledomains/googledomains.go index a87895c60..b5eed0b03 100644 --- a/providers/dns/googledomains/googledomains.go +++ b/providers/dns/googledomains/googledomains.go @@ -2,17 +2,12 @@ package googledomains import ( - "context" "errors" - "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" - "github.com/go-acme/lego/v4/platform/config/env" - "google.golang.org/api/acmedns/v1" - "google.golang.org/api/option" ) // Environment variables names. @@ -25,8 +20,7 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) -// static compile-time check on interface implementation. -var _ challenge.Provider = &DNSProvider{} +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { @@ -38,103 +32,29 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { - return &Config{ - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), - HTTPClient: &http.Client{ - Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), - }, - } + return &Config{} } +type DNSProvider struct{} + // NewDNSProvider returns the Google Domains DNS provider with a default configuration. func NewDNSProvider() (*DNSProvider, error) { - values, err := env.Get(EnvAccessToken) - if err != nil { - return nil, fmt.Errorf("googledomains: %w", err) - } - - config := NewDefaultConfig() - config.AccessToken = values[EnvAccessToken] - - return NewDNSProviderConfig(config) + return NewDNSProviderConfig(&Config{}) } // NewDNSProviderConfig returns the Google Domains DNS provider with the provided config. -func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { - if config == nil { - return nil, errors.New("googledomains: the configuration of the DNS provider is nil") - } - - if config.AccessToken == "" { - return nil, errors.New("googledomains: access token is missing") - } - - service, err := acmedns.NewService(context.Background(), option.WithHTTPClient(config.HTTPClient)) - if err != nil { - return nil, fmt.Errorf("googledomains: error creating acme dns service: %w", err) - } - - return &DNSProvider{ - config: config, - acmedns: service, - }, nil +func NewDNSProviderConfig(_ *Config) (*DNSProvider, error) { + return nil, errors.New("googledomains: provider has shut down") } -type DNSProvider struct { - config *Config - acmedns *acmedns.Service -} - -func (d *DNSProvider) Present(domain, token, keyAuth string) error { - zone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) - if err != nil { - return fmt.Errorf("googledomains: could not find zone for domain %q: %w", domain, err) - } - - rotateReq := acmedns.RotateChallengesRequest{ - AccessToken: d.config.AccessToken, - RecordsToAdd: []*acmedns.AcmeTxtRecord{getAcmeTxtRecord(domain, keyAuth)}, - KeepExpiredRecords: false, - } - - call := d.acmedns.AcmeChallengeSets.RotateChallenges(zone, &rotateReq) - _, err = call.Do() - if err != nil { - return fmt.Errorf("googledomains: error adding challenge for domain %s: %w", domain, err) - } +func (d *DNSProvider) Present(_, _, _ string) error { return nil } -func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - zone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) - if err != nil { - return fmt.Errorf("googledomains: could not find zone for domain %q: %w", domain, err) - } - - rotateReq := acmedns.RotateChallengesRequest{ - AccessToken: d.config.AccessToken, - RecordsToRemove: []*acmedns.AcmeTxtRecord{getAcmeTxtRecord(domain, keyAuth)}, - KeepExpiredRecords: false, - } - - call := d.acmedns.AcmeChallengeSets.RotateChallenges(zone, &rotateReq) - _, err = call.Do() - if err != nil { - return fmt.Errorf("googledomains: error cleaning up challenge for domain %s: %w", domain, err) - } +func (d *DNSProvider) CleanUp(_, _, _ string) error { return nil } func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.config.PropagationTimeout, d.config.PollingInterval -} - -func getAcmeTxtRecord(domain, keyAuth string) *acmedns.AcmeTxtRecord { - challengeInfo := dns01.GetChallengeInfo(domain, keyAuth) - - return &acmedns.AcmeTxtRecord{ - Fqdn: challengeInfo.EffectiveFQDN, - Digest: challengeInfo.Value, - } + return dns01.DefaultPropagationTimeout, dns01.DefaultPollingInterval } diff --git a/providers/dns/googledomains/googledomains.toml b/providers/dns/googledomains/googledomains.toml index 97e5452cc..52330795d 100644 --- a/providers/dns/googledomains/googledomains.toml +++ b/providers/dns/googledomains/googledomains.toml @@ -1,21 +1,23 @@ Name = "Google Domains" -Description = '''''' -URL = "https://domains.google" +Description = ''' +The Google Domains DNS provider has shut down. +''' +URL = "https://github.com/go-acme/lego/issues/2553" Code = "googledomains" Since = "v4.11.0" Example = ''' GOOGLE_DOMAINS_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns googledomains -d '*.example.com' -d example.com run +lego --dns googledomains -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] GOOGLE_DOMAINS_ACCESS_TOKEN = "Access token" [Configuration.Additional] - GOOGLE_DOMAINS_POLLING_INTERVAL = "Time between DNS propagation check" - GOOGLE_DOMAINS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - GOOGLE_DOMAINS_HTTP_TIMEOUT = "API request timeout" + GOOGLE_DOMAINS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + GOOGLE_DOMAINS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + GOOGLE_DOMAINS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] GoClient = "https://github.com/googleapis/google-api-go-client" diff --git a/providers/dns/gravity/gravity.go b/providers/dns/gravity/gravity.go new file mode 100644 index 000000000..b0bbb2fcb --- /dev/null +++ b/providers/dns/gravity/gravity.go @@ -0,0 +1,209 @@ +// Package gravity implements a DNS provider for solving the DNS-01 challenge using Gravity. +package gravity + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/gravity/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/google/uuid" +) + +// Environment variables names. +const ( + envNamespace = "GRAVITY_" + + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "PASSWORD" + EnvServerURL = envNamespace + "SERVER_URL" + + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" + EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Username string + Password string + ServerURL string + + PropagationTimeout time.Duration + PollingInterval time.Duration + SequenceInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, 1*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + records map[string]internal.Record + recordsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for Gravity. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvPassword, EnvServerURL) + if err != nil { + return nil, fmt.Errorf("gravity: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + config.ServerURL = values[EnvServerURL] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Gravity. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("gravity: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.ServerURL, config.Username, config.Password) + if err != nil { + return nil, fmt.Errorf("gravity: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + records: make(map[string]internal.Record), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + _, err := d.client.Login(ctx) + if err != nil { + return fmt.Errorf("gravity: login: %w", err) + } + + zone, err := d.findZone(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("gravity: %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) + if err != nil { + return fmt.Errorf("gravity: %w", err) + } + + id := uuid.New() + + record := internal.Record{ + Data: info.Value, + Hostname: subDomain, + Type: "TXT", + UID: id.String(), + } + + err = d.client.CreateDNSRecord(ctx, zone, record) + if err != nil { + return fmt.Errorf("gravity: create DNS record: %w", err) + } + + d.recordsMu.Lock() + + record.Fqdn = zone + d.records[token] = record + d.recordsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + d.recordsMu.Lock() + record, ok := d.records[token] + d.recordsMu.Unlock() + + if !ok { + return fmt.Errorf("gravity: unknown record for '%s' '%s'", info.EffectiveFQDN, token) + } + + err := d.client.DeleteDNSRecord(context.Background(), record.Fqdn, record) + if err != nil { + return fmt.Errorf("gravity: delete record: %w", err) + } + + d.recordsMu.Lock() + delete(d.records, token) + d.recordsMu.Unlock() + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Sequential implements the [dns01.sequential] interface. +// It changes the behavior of the provider to resolve DNS challenges sequentially. +// Returns the interval between each iteration. +// +// Gravity supports adding multiple records for the same domain, but the DNS server doesn't work as expected: +// if you call the DNS server, it will answer only the latest record instead of all of them. +func (d *DNSProvider) Sequential() time.Duration { + return d.config.SequenceInterval +} + +func (d *DNSProvider) findZone(ctx context.Context, effectiveFQDN string) (string, error) { + var zone string + + for fqdn := range dns01.DomainsSeq(effectiveFQDN) { + zones, err := d.client.GetDNSZones(ctx, fqdn) + if err != nil { + return "", fmt.Errorf("get DNS zones: %w", err) + } + + if len(zones) != 0 { + zone = zones[0].Name + break + } + } + + if zone == "" { + return "", fmt.Errorf("could not find zone for %q", effectiveFQDN) + } + + return zone, nil +} diff --git a/providers/dns/gravity/gravity.toml b/providers/dns/gravity/gravity.toml new file mode 100644 index 000000000..87a303839 --- /dev/null +++ b/providers/dns/gravity/gravity.toml @@ -0,0 +1,26 @@ +Name = "Gravity" +Description = '''''' +URL = "https://gravity.beryju.io/" +Code = "gravity" +Since = "v4.30.0" + +Example = ''' +GRAVITY_SERVER_URL="https://example.org:1234" \ +GRAVITY_USERNAME="xxxxxxxxxxxxxxxxxxxxx" \ +GRAVITY_PASSWORD="yyyyyyyyyyyyyyyyyyyyy" \ +lego --dns gravity -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + GRAVITY_SERVER_URL = "URL of the server" + GRAVITY_USERNAME = "Username" + GRAVITY_PASSWORD = "Password" + [Configuration.Additional] + GRAVITY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + GRAVITY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + GRAVITY_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 1)" + GRAVITY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://gravity.beryju.io/docs/api/reference/" diff --git a/providers/dns/gravity/gravity_test.go b/providers/dns/gravity/gravity_test.go new file mode 100644 index 000000000..b59b856fe --- /dev/null +++ b/providers/dns/gravity/gravity_test.go @@ -0,0 +1,254 @@ +package gravity + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/go-acme/lego/v4/providers/dns/gravity/internal" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvUsername, + EnvPassword, + EnvServerURL, +).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "secret", + EnvServerURL: "https://example.org:1234", + }, + }, + { + desc: "missing EnvUsername", + envVars: map[string]string{ + EnvUsername: "", + EnvPassword: "secret", + EnvServerURL: "https://example.org:1234", + }, + expected: "gravity: some credentials information are missing: GRAVITY_USERNAME", + }, + { + desc: "missing EnvPassword", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "", + EnvServerURL: "https://example.org:1234", + }, + expected: "gravity: some credentials information are missing: GRAVITY_PASSWORD", + }, + { + desc: "missing EnvServerURL", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "secret", + EnvServerURL: "", + }, + expected: "gravity: some credentials information are missing: GRAVITY_SERVER_URL", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "gravity: some credentials information are missing: GRAVITY_USERNAME,GRAVITY_PASSWORD,GRAVITY_SERVER_URL", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + username string + password string + serverURL string + expected string + }{ + { + desc: "success", + username: "user", + password: "secret", + serverURL: "https://example.org:1234", + }, + { + desc: "missing username", + username: "", + password: "secret", + serverURL: "https://example.org:1234", + expected: "gravity: credentials missing", + }, + { + desc: "missing password", + username: "user", + password: "", + serverURL: "https://example.org:1234", + expected: "gravity: credentials missing", + }, + { + desc: "missing server URL", + username: "user", + password: "secret", + serverURL: "", + expected: "gravity: server URL missing", + }, + { + desc: "missing credentials", + expected: "gravity: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Username = test.username + config.Password = test.password + config.ServerURL = test.serverURL + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + + config.Username = "user" + config.Password = "secret" + config.ServerURL = server.URL + + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("POST /api/v1/auth/login", + servermock.ResponseFromInternal("login.json"), + servermock.CheckRequestJSONBodyFromInternal("login-request.json")). + Route("GET /api/v1/dns/", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Query().Get("name") != "example.com." { + servermock.ResponseFromInternal("zones.json").ServeHTTP(rw, req) + return + } + + servermock.ResponseFromInternal("zones_empty.json").ServeHTTP(rw, req) + }), + ). + Route("POST /api/v1/dns/zones/records", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + servermock.CheckQueryParameter().Strict(). + With("zone", "example.com."). + WithRegexp("uid", `\w{8}-\w{4}-\w{4}-\w{4}-\w{12}`). + With("hostname", "_acme-challenge")). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("DELETE /api/v1/dns/zones/records", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + servermock.CheckQueryParameter().Strict(). + With("zone", "example.com."). + With("uid", "123"). + With("type", "TXT"). + With("hostname", "_acme-challenge")). + Build(t) + + provider.records["abc"] = internal.Record{ + Fqdn: "example.com.", + Hostname: "_acme-challenge", + Type: "TXT", + UID: "123", + } + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/gravity/internal/client.go b/providers/dns/gravity/internal/client.go new file mode 100644 index 000000000..41c6294c3 --- /dev/null +++ b/providers/dns/gravity/internal/client.go @@ -0,0 +1,234 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" + "golang.org/x/net/publicsuffix" +) + +// Client the Gravity API client. +type Client struct { + username string + password string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(serverURL, username, password string) (*Client, error) { + if username == "" || password == "" { + return nil, errors.New("credentials missing") + } + + if serverURL == "" { + return nil, errors.New("server URL missing") + } + + baseURL, err := url.Parse(serverURL) + if err != nil { + return nil, err + } + + return &Client{ + username: username, + password: password, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) Login(ctx context.Context) (*Auth, error) { + jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + if err != nil { + return nil, err + } + + c.HTTPClient.Jar = jar + + login := Login{ + Username: c.username, + Password: c.password, + } + + endpoint := c.baseURL.JoinPath("api", "v1", "auth", "login") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, login) + if err != nil { + return nil, err + } + + result := &Auth{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +func (c *Client) Me(ctx context.Context) (*UserInfo, error) { + endpoint := c.baseURL.JoinPath("api", "v1", "auth", "me") + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + result := &UserInfo{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, err +} + +func (c *Client) GetDNSZones(ctx context.Context, name string) ([]Zone, error) { + endpoint := c.baseURL.JoinPath("api", "v1", "dns", "zones") + + if name != "" { + query := endpoint.Query() + query.Set("name", name) + endpoint.RawQuery = query.Encode() + } + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + result := Zones{} + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result.Zones, nil +} + +func (c *Client) CreateDNSRecord(ctx context.Context, zone string, record Record) error { + endpoint := c.baseURL.JoinPath("api", "v1", "dns", "zones", "records") + + query := endpoint.Query() + + query.Set("zone", zone) + query.Set("hostname", record.Hostname) + + // When the UID is the same as an existing one, the record is updated, else a new record is created. + // An explicit UID is not required to create a record. + if record.UID != "" { + query.Set("uid", record.UID) + } + + endpoint.RawQuery = query.Encode() + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) DeleteDNSRecord(ctx context.Context, zone string, record Record) error { + endpoint := c.baseURL.JoinPath("api", "v1", "dns", "zones", "records") + + query := endpoint.Query() + + query.Set("zone", zone) + query.Set("hostname", record.Hostname) + query.Set("uid", record.UID) + query.Set("type", record.Type) + + endpoint.RawQuery = query.Encode() + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) do(req *http.Request, result any) error { + useragent.SetHeader(req.Header) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/gravity/internal/client_test.go b/providers/dns/gravity/internal/client_test.go new file mode 100644 index 000000000..98b17c59e --- /dev/null +++ b/providers/dns/gravity/internal/client_test.go @@ -0,0 +1,160 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "user", "secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + ) +} + +func TestClient_Login(t *testing.T) { + client := mockBuilder(). + Route("POST /api/v1/auth/login", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + http.SetCookie(rw, &http.Cookie{ + Name: "gravity_session", + Value: "session_id", + Path: "/", + }) + + servermock.ResponseFromFixture("login.json").ServeHTTP(rw, req) + }), + servermock.CheckRequestJSONBodyFromFixture("login-request.json")). + Build(t) + + auth, err := client.Login(t.Context()) + require.NoError(t, err) + + cookies := client.HTTPClient.Jar.Cookies(client.baseURL) + + require.Len(t, cookies, 1) + + assert.Equal(t, "gravity_session", cookies[0].Name) + assert.Equal(t, "session_id", cookies[0].Value) + + expected := &Auth{Successful: true} + + assert.Equal(t, expected, auth) +} + +func TestClient_Login_error(t *testing.T) { + client := mockBuilder(). + Route("POST /api/v1/auth/login", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + _, err := client.Login(t.Context()) + require.EqualError(t, err, "status: UNAUTHENTICATED, error: unauthenticated, additionalProp1: string") +} + +func TestClient_Me(t *testing.T) { + client := mockBuilder(). + Route("GET /api/v1/auth/me", + servermock.ResponseFromFixture("me.json")). + Build(t) + + info, err := client.Me(t.Context()) + require.NoError(t, err) + + expected := &UserInfo{ + Username: "admin", + Authenticated: true, + Permissions: []Permission{{ + Methods: []string{"GET", "POST", "PUT", "HEAD", "DELETE"}, + Path: "/*", + }}, + } + + assert.Equal(t, expected, info) +} + +func TestClient_GetDNSZones(t *testing.T) { + client := mockBuilder(). + Route("GET /api/v1/dns/", + servermock.ResponseFromFixture("zones.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com.")). + Build(t) + + zones, err := client.GetDNSZones(t.Context(), "example.com.") + require.NoError(t, err) + + expected := []Zone{{ + Name: "example.com.", + HandlerConfigs: []HandlerConfig{ + {Type: "memory"}, + {Type: "etcd"}, + }, + DefaultTTL: 86400, + RecordCount: 1, + }} + + assert.Equal(t, expected, zones) +} + +func TestClient_CreateDNSRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /api/v1/dns/zones/records", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + servermock.CheckRequestJSONBodyFromFixture("create_record-request.json"), + servermock.CheckQueryParameter().Strict(). + With("zone", "example.com."). + With("uid", "123"). + With("hostname", "_acme-challenge")). + Build(t) + + record := Record{ + Data: "txtTXTtxt", + Hostname: "_acme-challenge", + Type: "TXT", + UID: "123", + } + + err := client.CreateDNSRecord(t.Context(), "example.com.", record) + require.NoError(t, err) +} + +func TestClient_DeleteDNSRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /api/v1/dns/zones/records", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + servermock.CheckQueryParameter().Strict(). + With("zone", "example.com."). + With("uid", "123"). + With("type", "TXT"). + With("hostname", "_acme-challenge")). + Build(t) + + record := Record{ + Data: "txtTXTtxt", + Hostname: "_acme-challenge", + Type: "TXT", + UID: "123", + } + + err := client.DeleteDNSRecord(t.Context(), "example.com.", record) + require.NoError(t, err) +} diff --git a/providers/dns/gravity/internal/fixtures/create_record-request.json b/providers/dns/gravity/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..d671d1342 --- /dev/null +++ b/providers/dns/gravity/internal/fixtures/create_record-request.json @@ -0,0 +1,6 @@ +{ + "data": "txtTXTtxt", + "hostname": "_acme-challenge", + "type": "TXT", + "uid": "123" +} diff --git a/providers/dns/gravity/internal/fixtures/error.json b/providers/dns/gravity/internal/fixtures/error.json new file mode 100644 index 000000000..38b78fcca --- /dev/null +++ b/providers/dns/gravity/internal/fixtures/error.json @@ -0,0 +1,8 @@ +{ + "code": 0, + "context": { + "additionalProp1": "string" + }, + "error": "unauthenticated", + "status": "UNAUTHENTICATED" +} diff --git a/providers/dns/gravity/internal/fixtures/login-request.json b/providers/dns/gravity/internal/fixtures/login-request.json new file mode 100644 index 000000000..c641cd3e5 --- /dev/null +++ b/providers/dns/gravity/internal/fixtures/login-request.json @@ -0,0 +1,4 @@ +{ + "username": "user", + "password": "secret" +} diff --git a/providers/dns/gravity/internal/fixtures/login.json b/providers/dns/gravity/internal/fixtures/login.json new file mode 100644 index 000000000..b9ae7145f --- /dev/null +++ b/providers/dns/gravity/internal/fixtures/login.json @@ -0,0 +1,3 @@ +{ + "successful": true +} diff --git a/providers/dns/gravity/internal/fixtures/me.json b/providers/dns/gravity/internal/fixtures/me.json new file mode 100644 index 000000000..881a2ca5f --- /dev/null +++ b/providers/dns/gravity/internal/fixtures/me.json @@ -0,0 +1,16 @@ +{ + "username": "admin", + "authenticated": true, + "permissions": [ + { + "path": "/*", + "methods": [ + "GET", + "POST", + "PUT", + "HEAD", + "DELETE" + ] + } + ] +} diff --git a/providers/dns/gravity/internal/fixtures/me_unauthenticated.json b/providers/dns/gravity/internal/fixtures/me_unauthenticated.json new file mode 100644 index 000000000..67698b8e2 --- /dev/null +++ b/providers/dns/gravity/internal/fixtures/me_unauthenticated.json @@ -0,0 +1,5 @@ +{ + "username": "", + "authenticated": false, + "permissions": null +} diff --git a/providers/dns/gravity/internal/fixtures/zones.json b/providers/dns/gravity/internal/fixtures/zones.json new file mode 100644 index 000000000..53a8df6c1 --- /dev/null +++ b/providers/dns/gravity/internal/fixtures/zones.json @@ -0,0 +1,19 @@ +{ + "zones": [ + { + "name": "example.com.", + "handlerConfigs": [ + { + "type": "memory" + }, + { + "type": "etcd" + } + ], + "defaultTTL": 86400, + "authoritative": false, + "hook": "", + "recordCount": 1 + } + ] +} diff --git a/providers/dns/gravity/internal/fixtures/zones_empty.json b/providers/dns/gravity/internal/fixtures/zones_empty.json new file mode 100644 index 000000000..d8b70b45e --- /dev/null +++ b/providers/dns/gravity/internal/fixtures/zones_empty.json @@ -0,0 +1,3 @@ +{ + "zones": null +} diff --git a/providers/dns/gravity/internal/types.go b/providers/dns/gravity/internal/types.go new file mode 100644 index 000000000..872bc070f --- /dev/null +++ b/providers/dns/gravity/internal/types.go @@ -0,0 +1,82 @@ +package internal + +import ( + "fmt" + "strings" +) + +type APIError struct { + Status string `json:"status"` + ErrorMsg string `json:"error"` + Code int `json:"code"` + Context map[string]string `json:"context"` +} + +func (a *APIError) Error() string { + msg := new(strings.Builder) + + _, _ = fmt.Fprintf(msg, "status: %s, error: %s", a.Status, a.ErrorMsg) + + if a.Code != 0 { + _, _ = fmt.Fprintf(msg, ", code: %d", a.Code) + } + + if len(a.Context) != 0 { + for k, v := range a.Context { + _, _ = fmt.Fprintf(msg, ", %s: %s", k, v) + } + } + + return msg.String() +} + +type Login struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type Auth struct { + Successful bool `json:"successful"` +} + +type UserInfo struct { + Username string `json:"username"` + Authenticated bool `json:"authenticated"` + Permissions []Permission `json:"permissions"` +} + +type Permission struct { + Methods []string `json:"methods"` + Path string `json:"path"` +} + +type Zones struct { + Zones []Zone `json:"zones"` +} + +type Zone struct { + Name string `json:"name"` + HandlerConfigs []HandlerConfig `json:"handlerConfigs"` + DefaultTTL int `json:"defaultTTL"` + Authoritative bool `json:"authoritative"` + Hook string `json:"hook"` + RecordCount int `json:"recordCount"` +} + +type HandlerConfig struct { + Type string `json:"type"` + CacheTTL int `json:"cache_ttl,omitempty"` + To []string `json:"to,omitempty"` +} + +type Record struct { + Data string `json:"data,omitempty"` + Fqdn string `json:"fqdn,omitempty"` + Hostname string `json:"hostname,omitempty"` + MxPreference int `json:"mxPreference,omitempty"` + SrvPort int `json:"srvPort,omitempty"` + SrvPriority int `json:"srvPriority,omitempty"` + SrvWeight int `json:"srvWeight,omitempty"` + Type string `json:"type,omitempty"` + UID string `json:"uid,omitempty"` +} diff --git a/providers/dns/hetzner/hetzner.go b/providers/dns/hetzner/hetzner.go index 5b2112d73..bae985b3e 100644 --- a/providers/dns/hetzner/hetzner.go +++ b/providers/dns/hetzner/hetzner.go @@ -2,34 +2,41 @@ package hetzner import ( - "context" "errors" - "fmt" "net/http" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" - "github.com/go-acme/lego/v4/providers/dns/hetzner/internal" + "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/hetznerv1" + "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/legacy" +) + +// Environment variables names. +const ( + // Deprecated: use EnvAPIToken instead. + EnvAPIKey = legacy.EnvAPIKey + EnvAPIToken = hetznerv1.EnvAPIToken + + EnvTTL = hetznerv1.EnvTTL + EnvPropagationTimeout = hetznerv1.EnvPropagationTimeout + EnvPollingInterval = hetznerv1.EnvPollingInterval + EnvHTTPTimeout = hetznerv1.EnvHTTPTimeout ) const minTTL = 60 -// Environment variables names. -const ( - envNamespace = "HETZNER_" - - EnvAPIKey = envNamespace + "API_KEY" - - EnvTTL = envNamespace + "TTL" - EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" - EnvPollingInterval = envNamespace + "POLLING_INTERVAL" - EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" -) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { - APIKey string + // Deprecated: use APIToken instead + APIKey string + + APIToken string + PropagationTimeout time.Duration PollingInterval time.Duration TTL int @@ -41,7 +48,7 @@ func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, @@ -50,22 +57,41 @@ func NewDefaultConfig() *Config { // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { - config *Config - client *internal.Client + provider challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for hetzner. -// Credentials must be passed in the environment variable: HETZNER_API_KEY. func NewDNSProvider() (*DNSProvider, error) { - values, err := env.Get(EnvAPIKey) - if err != nil { - return nil, fmt.Errorf("hetzner: %w", err) + foundAPIToken := env.GetOrFile(EnvAPIToken) != "" + foundAPIKey := env.GetOrFile(EnvAPIKey) != "" + + switch { + case foundAPIToken: + provider, err := hetznerv1.NewDNSProvider() + if err != nil { + return nil, err + } + + return &DNSProvider{provider: provider}, nil + + case foundAPIKey: + log.Warnf("APIKey (legacy Hetzner DNS API) is deprecated, please use APIToken (Hetzner Cloud API) instead.") + + provider, err := legacy.NewDNSProvider() + if err != nil { + return nil, err + } + + return &DNSProvider{provider: provider}, nil + + default: + provider, err := hetznerv1.NewDNSProvider() + if err != nil { + return nil, err + } + + return &DNSProvider{provider: provider}, nil } - - config := NewDefaultConfig() - config.APIKey = values[EnvAPIKey] - - return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for hetzner. @@ -74,98 +100,57 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("hetzner: the configuration of the DNS provider is nil") } - if config.APIKey == "" { - return nil, errors.New("hetzner: credentials missing") + switch { + case config.APIToken != "": + cfg := &hetznerv1.Config{ + APIToken: config.APIToken, + PropagationTimeout: config.PropagationTimeout, + PollingInterval: config.PollingInterval, + TTL: config.TTL, + HTTPClient: config.HTTPClient, + } + + provider, err := hetznerv1.NewDNSProviderConfig(cfg) + if err != nil { + return nil, err + } + + return &DNSProvider{provider: provider}, nil + + case config.APIKey != "": + log.Warnf("%s (legacy Hetzner DNS API) is deprecated, please use %s (Hetzner Cloud API) instead.", EnvAPIKey, EnvAPIToken) + + cfg := &legacy.Config{ + APIKey: config.APIKey, + PropagationTimeout: config.PropagationTimeout, + PollingInterval: config.PollingInterval, + TTL: config.TTL, + HTTPClient: config.HTTPClient, + } + + provider, err := legacy.NewDNSProviderConfig(cfg) + if err != nil { + return nil, err + } + + return &DNSProvider{provider: provider}, nil } - if config.TTL < minTTL { - return nil, fmt.Errorf("hetzner: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) - } - - client := internal.NewClient(config.APIKey) - - if config.HTTPClient != nil { - client.HTTPClient = config.HTTPClient - } - - return &DNSProvider{config: config, client: client}, nil + return nil, errors.New("hetzner: credentials missing") } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.config.PropagationTimeout, d.config.PollingInterval + return d.provider.Timeout() } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) - if err != nil { - return fmt.Errorf("hetzner: could not find zone for domain %q: %w", domain, err) - } - - zone := dns01.UnFqdn(authZone) - - ctx := context.Background() - - zoneID, err := d.client.GetZoneID(ctx, zone) - if err != nil { - return fmt.Errorf("hetzner: %w", err) - } - - subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) - if err != nil { - return fmt.Errorf("hetzner: %w", err) - } - - record := internal.DNSRecord{ - Type: "TXT", - Name: subDomain, - Value: info.Value, - TTL: d.config.TTL, - ZoneID: zoneID, - } - - if err := d.client.CreateRecord(ctx, record); err != nil { - return fmt.Errorf("hetzner: failed to add TXT record: fqdn=%s, zoneID=%s: %w", info.EffectiveFQDN, zoneID, err) - } - - return nil + return d.provider.Present(domain, token, keyAuth) } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) - if err != nil { - return fmt.Errorf("hetzner: could not find zone for domain %q: %w", domain, err) - } - - zone := dns01.UnFqdn(authZone) - - ctx := context.Background() - - zoneID, err := d.client.GetZoneID(ctx, zone) - if err != nil { - return fmt.Errorf("hetzner: %w", err) - } - - subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) - if err != nil { - return fmt.Errorf("hetzner: %w", err) - } - - record, err := d.client.GetTxtRecord(ctx, subDomain, info.Value, zoneID) - if err != nil { - return fmt.Errorf("hetzner: %w", err) - } - - if err := d.client.DeleteRecord(ctx, record.ID); err != nil { - return fmt.Errorf("hetzner: failed to delete TXT record: id=%s, name=%s: %w", record.ID, record.Name, err) - } - - return nil + return d.provider.CleanUp(domain, token, keyAuth) } diff --git a/providers/dns/hetzner/hetzner.toml b/providers/dns/hetzner/hetzner.toml index 77d23acb8..40d4cea72 100644 --- a/providers/dns/hetzner/hetzner.toml +++ b/providers/dns/hetzner/hetzner.toml @@ -5,18 +5,18 @@ Code = "hetzner" Since = "v3.7.0" Example = ''' -HETZNER_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ -lego --email you@example.com --dns hetzner -d '*.example.com' -d example.com run +HETZNER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns hetzner -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] - HETZNER_API_KEY = "API key" + HETZNER_API_TOKEN = "API token" [Configuration.Additional] - HETZNER_POLLING_INTERVAL = "Time between DNS propagation check" - HETZNER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - HETZNER_TTL = "The TTL of the TXT record used for the DNS challenge" - HETZNER_HTTP_TIMEOUT = "API request timeout" + HETZNER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + HETZNER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + HETZNER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + HETZNER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] - API = "https://dns.hetzner.com/api-docs" + API = "https://docs.hetzner.cloud/reference/cloud#dns" diff --git a/providers/dns/hetzner/hetzner_test.go b/providers/dns/hetzner/hetzner_test.go index d028fd06b..430f0270b 100644 --- a/providers/dns/hetzner/hetzner_test.go +++ b/providers/dns/hetzner/hetzner_test.go @@ -3,52 +3,72 @@ package hetzner import ( "testing" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/hetznerv1" + "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/legacy" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -const envDomain = envNamespace + "DOMAIN" - -var envTest = tester.NewEnvTest( - EnvAPIKey). - WithDomain(envDomain) +var envTest = tester.NewEnvTest(EnvAPIKey, EnvAPIToken) func TestNewDNSProvider(t *testing.T) { testCases := []struct { - desc string - envVars map[string]string - expected string + desc string + envVars map[string]string + + expectedProvider challenge.ProviderTimeout + expectedError string }{ { - desc: "success", + desc: "success (v1)", + envVars: map[string]string{ + EnvAPIToken: "123", + }, + expectedProvider: &hetznerv1.DNSProvider{}, + }, + { + desc: "success (legacy)", envVars: map[string]string{ EnvAPIKey: "123", }, + expectedProvider: &legacy.DNSProvider{}, + }, + { + desc: "success (both)", + envVars: map[string]string{ + EnvAPIKey: "123", + EnvAPIToken: "123", + }, + expectedProvider: &hetznerv1.DNSProvider{}, }, { desc: "missing credentials", envVars: map[string]string{ - EnvAPIKey: "", + EnvAPIKey: "", + EnvAPIToken: "", }, - expected: "hetzner: some credentials information are missing: HETZNER_API_KEY", + expectedError: "hetzner: some credentials information are missing: HETZNER_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() - if test.expected == "" { + if test.expectedError == "" { require.NoError(t, err) + assert.IsType(t, test.expectedProvider, p.provider) require.NotNil(t, p) - require.NotNil(t, p.config) } else { - require.EqualError(t, err, test.expected) + require.EqualError(t, err, test.expectedError) } }) } @@ -58,68 +78,53 @@ func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string + apiToken string ttl int - expected string + + expectedProvider challenge.ProviderTimeout + expectedError string }{ { - desc: "success", - ttl: minTTL, - apiKey: "123", + desc: "success (v1)", + ttl: minTTL, + apiToken: "123", + expectedProvider: &hetznerv1.DNSProvider{}, }, { - desc: "missing credentials", - ttl: minTTL, - expected: "hetzner: credentials missing", + desc: "success (legacy)", + ttl: minTTL, + apiKey: "456", + expectedProvider: &legacy.DNSProvider{}, }, { - desc: "invalid TTL", - apiKey: "123", - ttl: 10, - expected: "hetzner: invalid TTL, TTL (10) must be greater than 60", + desc: "success (both)", + ttl: minTTL, + apiToken: "123", + apiKey: "456", + expectedProvider: &hetznerv1.DNSProvider{}, + }, + { + desc: "missing credentials", + ttl: minTTL, + expectedError: "hetzner: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() + config.APIToken = test.apiToken config.APIKey = test.apiKey config.TTL = test.ttl p, err := NewDNSProviderConfig(config) - if test.expected == "" { + if test.expectedError == "" { require.NoError(t, err) - require.NotNil(t, p) - require.NotNil(t, p.config) + assert.IsType(t, test.expectedProvider, p.provider) } else { - require.EqualError(t, err, test.expected) + require.EqualError(t, err, test.expectedError) } }) } } - -func TestLivePresent(t *testing.T) { - if !envTest.IsLiveTest() { - t.Skip("skipping live test") - } - - envTest.RestoreEnv() - provider, err := NewDNSProvider() - require.NoError(t, err) - - err = provider.Present(envTest.GetDomain(), "", "123d==") - require.NoError(t, err) -} - -func TestLiveCleanUp(t *testing.T) { - if !envTest.IsLiveTest() { - t.Skip("skipping live test") - } - - envTest.RestoreEnv() - provider, err := NewDNSProvider() - require.NoError(t, err) - - err = provider.CleanUp(envTest.GetDomain(), "", "123d==") - require.NoError(t, err) -} diff --git a/providers/dns/hetzner/internal/client_test.go b/providers/dns/hetzner/internal/client_test.go deleted file mode 100644 index aa2175409..000000000 --- a/providers/dns/hetzner/internal/client_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package internal - -import ( - "context" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func setupTest(t *testing.T, apiKey string) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(apiKey) - client.baseURL, _ = url.Parse(server.URL) - client.HTTPClient = server.Client() - - return client, mux -} - -func TestClient_GetTxtRecord(t *testing.T) { - const zoneID = "zoneA" - const apiKey = "myKeyA" - - client, mux := setupTest(t, apiKey) - - mux.HandleFunc("/api/v1/records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authHeader) - if auth != apiKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - - zID := req.URL.Query().Get("zone_id") - if zID != zoneID { - http.Error(rw, fmt.Sprintf("invalid zone ID: %s", zID), http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/get_txt_record.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - record, err := client.GetTxtRecord(context.Background(), "test1", "txttxttxt", zoneID) - require.NoError(t, err) - - fmt.Println(record) -} - -func TestClient_CreateRecord(t *testing.T) { - const zoneID = "zoneA" - const apiKey = "myKeyB" - - client, mux := setupTest(t, apiKey) - - mux.HandleFunc("/api/v1/records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authHeader) - if auth != apiKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open("./fixtures/create_txt_record.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - record := DNSRecord{ - Name: "test", - Type: "TXT", - Value: "txttxttxt", - TTL: 600, - ZoneID: zoneID, - } - - err := client.CreateRecord(context.Background(), record) - require.NoError(t, err) -} - -func TestClient_DeleteRecord(t *testing.T) { - const apiKey = "myKeyC" - - client, mux := setupTest(t, apiKey) - - mux.HandleFunc("/api/v1/records/recordID", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authHeader) - if auth != apiKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - }) - - err := client.DeleteRecord(context.Background(), "recordID") - require.NoError(t, err) -} - -func TestClient_GetZoneID(t *testing.T) { - const apiKey = "myKeyD" - - client, mux := setupTest(t, apiKey) - - mux.HandleFunc("/api/v1/zones", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authHeader) - if auth != apiKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open("./fixtures/get_zone_id.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - zoneID, err := client.GetZoneID(context.Background(), "example.com") - require.NoError(t, err) - - assert.Equal(t, "zoneA", zoneID) -} diff --git a/providers/dns/hetzner/internal/hetznerv1/fixtures/add_rrset_records-request.json b/providers/dns/hetzner/internal/hetznerv1/fixtures/add_rrset_records-request.json new file mode 100644 index 000000000..210f84435 --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/fixtures/add_rrset_records-request.json @@ -0,0 +1,8 @@ +{ + "ttl": 120, + "records": [ + { + "value": "\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\"" + } + ] +} diff --git a/providers/dns/hetzner/internal/hetznerv1/fixtures/add_rrset_records.json b/providers/dns/hetzner/internal/hetznerv1/fixtures/add_rrset_records.json new file mode 100644 index 000000000..2341c7e6e --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/fixtures/add_rrset_records.json @@ -0,0 +1,17 @@ +{ + "action": { + "id": 1, + "command": "add_rrset_records", + "status": "running", + "progress": 50, + "started": "2016-01-30T23:55:00+00:00", + "finished": null, + "resources": [ + { + "id": 42, + "type": "zone" + } + ], + "error": null + } +} diff --git a/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_error.json b/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_error.json new file mode 100644 index 000000000..2a4472f67 --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_error.json @@ -0,0 +1,20 @@ +{ + "action": { + "id": 1, + "command": "remove_rrset_records", + "status": "error", + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:55:00+00:00", + "progress": 50, + "resources": [ + { + "id": 42, + "type": "zone" + } + ], + "error": { + "code": "action_failed", + "message": "Action failed" + } + } +} diff --git a/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_running.json b/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_running.json new file mode 100644 index 000000000..dcec6c2cd --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_running.json @@ -0,0 +1,16 @@ +{ + "action": { + "id": 1, + "command": "remove_rrset_records", + "status": "running", + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:55:00+00:00", + "progress": 50, + "resources": [ + { + "id": 42, + "type": "zone" + } + ] + } +} diff --git a/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_success.json b/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_success.json new file mode 100644 index 000000000..6b7267c07 --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_success.json @@ -0,0 +1,16 @@ +{ + "action": { + "id": 1, + "command": "remove_rrset_records", + "status": "success", + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:55:00+00:00", + "progress": 100, + "resources": [ + { + "id": 42, + "type": "zone" + } + ] + } +} diff --git a/providers/dns/hetzner/internal/hetznerv1/fixtures/remove_rrset_records-request.json b/providers/dns/hetzner/internal/hetznerv1/fixtures/remove_rrset_records-request.json new file mode 100644 index 000000000..982273b67 --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/fixtures/remove_rrset_records-request.json @@ -0,0 +1,7 @@ +{ + "records": [ + { + "value": "\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\"" + } + ] +} diff --git a/providers/dns/hetzner/internal/hetznerv1/fixtures/remove_rrset_records.json b/providers/dns/hetzner/internal/hetznerv1/fixtures/remove_rrset_records.json new file mode 100644 index 000000000..1b10dfd5e --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/fixtures/remove_rrset_records.json @@ -0,0 +1,17 @@ +{ + "action": { + "id": 1, + "command": "remove_rrset_records", + "status": "running", + "progress": 50, + "started": "2016-01-30T23:55:00+00:00", + "finished": null, + "resources": [ + { + "id": 42, + "type": "zone" + } + ], + "error": null + } +} diff --git a/providers/dns/hetzner/internal/hetznerv1/hetznerv1.go b/providers/dns/hetzner/internal/hetznerv1/hetznerv1.go new file mode 100644 index 000000000..b31c766ce --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/hetznerv1.go @@ -0,0 +1,209 @@ +// Package hetznerv1 implements a DNS provider for solving the DNS-01 challenge using Hetzner. +package hetznerv1 + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/cenkalti/backoff/v5" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/platform/wait" + "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/hetznerv1/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "golang.org/x/net/idna" +) + +// Environment variables names. +const ( + envNamespace = "HETZNER_" + + EnvAPIToken = envNamespace + "API_TOKEN" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIToken string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Hetzner. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIToken) + if err != nil { + return nil, fmt.Errorf("hetzner: %w", err) + } + + config := NewDefaultConfig() + config.APIToken = values[EnvAPIToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Hetzner. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("hetzner: the configuration of the DNS provider is nil") + } + + if config.APIToken == "" { + return nil, errors.New("hetzner: credentials missing") + } + + client, err := internal.NewClient( + clientdebug.Wrap( + internal.OAuthStaticAccessToken(config.HTTPClient, config.APIToken), + ), + ) + if err != nil { + return nil, fmt.Errorf("hetzner: %w", err) + } + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("hetzner: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("hetzner: %w", err) + } + + subDomainPunnycoded, err := idna.ToASCII(dns01.UnFqdn(subDomain)) + if err != nil { + return fmt.Errorf("hetzner: %w", err) + } + + zone, err := idna.ToASCII(dns01.UnFqdn(authZone)) + if err != nil { + return fmt.Errorf("hetzner: %w", err) + } + + records := []internal.Record{{Value: strconv.Quote(info.Value)}} + + action, err := d.client.AddRRSetRecords(ctx, zone, "TXT", subDomainPunnycoded, d.config.TTL, records) + if err != nil { + return fmt.Errorf("hetzner: add RRSet records: %w", err) + } + + err = d.waitAction(ctx, action.ID) + if err != nil { + return fmt.Errorf("hetzner: wait (add RRSet records): %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("hetzner: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("hetzner: %w", err) + } + + subDomainPunnycoded, err := idna.ToASCII(dns01.UnFqdn(subDomain)) + if err != nil { + return fmt.Errorf("hetzner: %w", err) + } + + zone, err := idna.ToASCII(dns01.UnFqdn(authZone)) + if err != nil { + return fmt.Errorf("hetzner: %w", err) + } + + records := []internal.Record{{Value: strconv.Quote(info.Value)}} + + action, err := d.client.RemoveRRSetRecords(ctx, zone, "TXT", subDomainPunnycoded, records) + if err != nil { + return fmt.Errorf("hetzner: remove RRSet records: %w", err) + } + + err = d.waitAction(ctx, action.ID) + if err != nil { + return fmt.Errorf("hetzner: wait (remove RRSet records): %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) waitAction(ctx context.Context, actionID int64) error { + return wait.Retry(ctx, + func() error { + result, err := d.client.GetAction(ctx, actionID) + if err != nil { + return backoff.Permanent(fmt.Errorf("get action %d: %w", actionID, err)) + } + + switch result.Status { + case internal.StatusRunning: + return fmt.Errorf("action %d is %s", actionID, internal.StatusRunning) + + case internal.StatusError: + return backoff.Permanent(fmt.Errorf("action %d: %s: %w", actionID, internal.StatusError, result.ErrorInfo)) + + default: + return nil + } + }, + backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)), + backoff.WithMaxElapsedTime(d.config.PropagationTimeout), + ) +} diff --git a/providers/dns/hetzner/internal/hetznerv1/hetznerv1_test.go b/providers/dns/hetzner/internal/hetznerv1/hetznerv1_test.go new file mode 100644 index 000000000..bf52baa35 --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/hetznerv1_test.go @@ -0,0 +1,232 @@ +package hetznerv1 + +import ( + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIToken).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIToken: "secret", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "hetzner: some credentials information are missing: HETZNER_API_TOKEN", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiToken string + expected string + }{ + { + desc: "success", + apiToken: "secret", + }, + { + desc: "missing credentials", + expected: "hetzner: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIToken = test.apiToken + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIToken = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + WithAuthorization("Bearer secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/add_records", + servermock.ResponseFromFixture("add_rrset_records.json"), + servermock.CheckRequestJSONBodyFromFixture("add_rrset_records-request.json")). + Route("GET /actions/1", + servermock.ResponseFromFixture("get_action_success.json")). + Build(t) + + err := provider.Present("example.com", "", "foobar") + require.NoError(t, err) +} + +func TestDNSProvider_Present_error(t *testing.T) { + provider := mockBuilder(). + Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/add_records", + servermock.ResponseFromFixture("add_rrset_records.json"), + servermock.CheckRequestJSONBodyFromFixture("add_rrset_records-request.json")). + Route("GET /actions/1", + servermock.ResponseFromFixture("get_action_error.json")). + Build(t) + + provider.config.PollingInterval = 20 * time.Millisecond + provider.config.PropagationTimeout = 1 * time.Second + + err := provider.Present("example.com", "", "foobar") + require.EqualError(t, err, "hetzner: wait (add RRSet records): action 1: error: action_failed: Action failed") +} + +func TestDNSProvider_Present_running(t *testing.T) { + provider := mockBuilder(). + Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/add_records", + servermock.ResponseFromFixture("add_rrset_records.json"), + servermock.CheckRequestJSONBodyFromFixture("add_rrset_records-request.json")). + Route("GET /actions/1", + servermock.ResponseFromFixture("get_action_running.json")). + Build(t) + + provider.config.PollingInterval = 20 * time.Millisecond + provider.config.PropagationTimeout = 1 * time.Second + + err := provider.Present("example.com", "", "foobar") + require.EqualError(t, err, "hetzner: wait (add RRSet records): action 1 is running") +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/remove_records", + servermock.ResponseFromFixture("remove_rrset_records.json"), + servermock.CheckRequestJSONBodyFromFixture("remove_rrset_records-request.json")). + Route("GET /actions/1", + servermock.ResponseFromFixture("get_action_success.json")). + Build(t) + + err := provider.CleanUp("example.com", "", "foobar") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp_error(t *testing.T) { + provider := mockBuilder(). + Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/remove_records", + servermock.ResponseFromFixture("remove_rrset_records.json"), + servermock.CheckRequestJSONBodyFromFixture("remove_rrset_records-request.json")). + Route("GET /actions/1", + servermock.ResponseFromFixture("get_action_error.json")). + Build(t) + + provider.config.PollingInterval = 20 * time.Millisecond + provider.config.PropagationTimeout = 1 * time.Second + + err := provider.CleanUp("example.com", "", "foobar") + require.EqualError(t, err, "hetzner: wait (remove RRSet records): action 1: error: action_failed: Action failed") +} + +func TestDNSProvider_CleanUp_running(t *testing.T) { + provider := mockBuilder(). + Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/remove_records", + servermock.ResponseFromFixture("remove_rrset_records.json"), + servermock.CheckRequestJSONBodyFromFixture("remove_rrset_records-request.json")). + Route("GET /actions/1", + servermock.ResponseFromFixture("get_action_running.json")). + Build(t) + + provider.config.PollingInterval = 20 * time.Millisecond + provider.config.PropagationTimeout = 1 * time.Second + + err := provider.CleanUp("example.com", "", "foobar") + require.EqualError(t, err, "hetzner: wait (remove RRSet records): action 1 is running") +} diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/client.go b/providers/dns/hetzner/internal/hetznerv1/internal/client.go new file mode 100644 index 000000000..2f29f642a --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/internal/client.go @@ -0,0 +1,183 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "golang.org/x/oauth2" +) + +const defaultBaseURL = "https://api.hetzner.cloud/v1" + +const ( + StatusRunning = "running" + StatusSuccess = "success" + StatusError = "error" +) + +// Client the Hetzner API client. +type Client struct { + BaseURL *url.URL + httpClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(hc *http.Client) (*Client, error) { + baseURL, _ := url.Parse(defaultBaseURL) + + if hc == nil { + hc = &http.Client{Timeout: 10 * time.Second} + } + + return &Client{ + BaseURL: baseURL, + httpClient: hc, + }, nil +} + +// AddRRSetRecords adds records to an RRSet. +// https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-add-records-to-an-rrset +func (c *Client) AddRRSetRecords(ctx context.Context, zoneIDName, recordType, recordName string, ttl int, records []Record) (*Action, error) { + endpoint := c.BaseURL.JoinPath("zones", zoneIDName, "rrsets", recordName, recordType, "actions", "add_records") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, RRSet{TTL: ttl, Records: records}) + if err != nil { + return nil, err + } + + var result ActionResponse + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result.Action, nil +} + +// RemoveRRSetRecords removes records from an RRSet. +// https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-remove-records-from-an-rrset +func (c *Client) RemoveRRSetRecords(ctx context.Context, zoneIDName, recordType, recordName string, records []Record) (*Action, error) { + endpoint := c.BaseURL.JoinPath("zones", zoneIDName, "rrsets", recordName, recordType, "actions", "remove_records") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, RRSet{Records: records}) + if err != nil { + return nil, err + } + + var result ActionResponse + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result.Action, nil +} + +// GetAction gets an action. +// https://docs.hetzner.cloud/reference/cloud#actions-get-an-action +func (c *Client) GetAction(ctx context.Context, id int64) (*Action, error) { + endpoint := c.BaseURL.JoinPath("actions", strconv.FormatInt(id, 10)) + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result ActionResponse + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result.Action, nil +} + +func (c *Client) do(req *http.Request, result any) error { + resp, err := c.httpClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} + +func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { + if client == nil { + client = &http.Client{Timeout: 5 * time.Second} + } + + client.Transport = &oauth2.Transport{ + Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), + Base: client.Transport, + } + + return client +} diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/client_test.go b/providers/dns/hetzner/internal/hetznerv1/internal/client_test.go new file mode 100644 index 000000000..6fd3d77a7 --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/internal/client_test.go @@ -0,0 +1,154 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + WithAuthorization("Bearer secret"), + ) +} + +func TestClient_AddRRSetRecords(t *testing.T) { + client := mockBuilder(). + Route("POST /zones/example.com/rrsets/www/TXT/actions/add_records", + servermock.ResponseFromFixture("add_rrset_records.json"), + servermock.CheckRequestJSONBodyFromFixture("add_rrset_records-request.json")). + Build(t) + + records := []Record{{ + Value: "198.51.100.1", + Comment: "My web server at Hetzner Cloud.", + }} + + result, err := client.AddRRSetRecords(t.Context(), "example.com", "TXT", "www", 3600, records) + require.NoError(t, err) + + expected := &Action{ + ID: 1, + Command: "add_rrset_records", + Status: "running", + Progress: 50, + Resources: []Resources{{ID: 590000000000000, Type: "zone"}}, + } + + assert.Equal(t, expected, result) +} + +func TestClient_AddRRSetRecords_error_invalid_input(t *testing.T) { + client := mockBuilder(). + Route("POST /zones/example.com/rrsets/www/TXT/actions/add_records", + servermock.ResponseFromFixture("error-invalid_input.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + records := []Record{{ + Value: "198.51.100.1", + Comment: "My web server at Hetzner Cloud.", + }} + + _, err := client.AddRRSetRecords(t.Context(), "example.com", "TXT", "www", 0, records) + require.EqualError(t, err, "invalid_input: invalid input in field 'broken_field': is too longfield: broken_field: is too long") +} + +func TestClient_AddRRSetRecords_error_resource_limit_exceeded(t *testing.T) { + client := mockBuilder(). + Route("POST /zones/example.com/rrsets/www/TXT/actions/add_records", + servermock.ResponseFromFixture("error-resource_limit_exceeded.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + records := []Record{{ + Value: "198.51.100.1", + Comment: "My web server at Hetzner Cloud.", + }} + + _, err := client.AddRRSetRecords(t.Context(), "example.com", "TXT", "www", 0, records) + require.EqualError(t, err, "resource_limit_exceeded: project limit exceededlimit: project_limit") +} + +func TestClient_AddRRSetRecords_error_deprecated_api_endpoint(t *testing.T) { + client := mockBuilder(). + Route("POST /zones/example.com/rrsets/www/TXT/actions/add_records", + servermock.ResponseFromFixture("error-deprecated_api_endpoint.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + records := []Record{{ + Value: "198.51.100.1", + Comment: "My web server at Hetzner Cloud.", + }} + + _, err := client.AddRRSetRecords(t.Context(), "example.com", "TXT", "www", 0, records) + require.EqualError(t, err, "deprecated_api_endpoint: API functionality was removed: https://docs.hetzner.cloud/changelog#2023-07-20-foo-endpoint-is-deprecated") +} + +func TestClient_RemoveRRSetRecords(t *testing.T) { + client := mockBuilder(). + Route("POST /zones/example.com/rrsets/www/TXT/actions/remove_records", + servermock.ResponseFromFixture("remove_rrset_records.json"), + servermock.CheckRequestJSONBodyFromFixture("remove_rrset_records-request.json")). + Build(t) + + records := []Record{{ + Value: "198.51.100.1", + Comment: "My web server at Hetzner Cloud.", + }} + + result, err := client.RemoveRRSetRecords(t.Context(), "example.com", "TXT", "www", records) + require.NoError(t, err) + + expected := &Action{ + ID: 1, + Command: "remove_rrset_records", + Status: "running", + Progress: 50, + Resources: []Resources{{ID: 42, Type: "zone"}}, + } + + assert.Equal(t, expected, result) +} + +func TestClient_GetAction(t *testing.T) { + client := mockBuilder(). + Route("GET /actions/123", servermock.ResponseFromFixture("get_action.json")). + Route("/", servermock.DumpRequest()). + Build(t) + + result, err := client.GetAction(t.Context(), 123) + require.NoError(t, err) + + expected := &Action{ + ID: 590000000000000, + Command: "start_resource", + Status: "running", + Progress: 100, + Resources: []Resources{{ID: 590000000000000, Type: "server"}}, + ErrorInfo: &ErrorInfo{ + Code: "action_failed", + Message: "Action failed", + }, + } + + assert.Equal(t, expected, result) +} diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records-request.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records-request.json new file mode 100644 index 000000000..cba0f34d3 --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records-request.json @@ -0,0 +1,9 @@ +{ + "ttl": 3600, + "records": [ + { + "value": "198.51.100.1", + "comment": "My web server at Hetzner Cloud." + } + ] +} diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records.json new file mode 100644 index 000000000..7267b02cb --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records.json @@ -0,0 +1,17 @@ +{ + "action": { + "id": 1, + "command": "add_rrset_records", + "status": "running", + "progress": 50, + "started": "2016-01-30T23:55:00+00:00", + "finished": null, + "resources": [ + { + "id": 590000000000000, + "type": "zone" + } + ], + "error": null + } +} diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-deprecated_api_endpoint.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-deprecated_api_endpoint.json new file mode 100644 index 000000000..4d8fb945d --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-deprecated_api_endpoint.json @@ -0,0 +1,9 @@ +{ + "error": { + "code": "deprecated_api_endpoint", + "message": "API functionality was removed", + "details": { + "announcement": "https://docs.hetzner.cloud/changelog#2023-07-20-foo-endpoint-is-deprecated" + } + } +} diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-invalid_input.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-invalid_input.json new file mode 100644 index 000000000..e05bf7a3e --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-invalid_input.json @@ -0,0 +1,16 @@ +{ + "error": { + "code": "invalid_input", + "message": "invalid input in field 'broken_field': is too long", + "details": { + "fields": [ + { + "name": "broken_field", + "messages": [ + "is too long" + ] + } + ] + } + } +} diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-resource_limit_exceeded.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-resource_limit_exceeded.json new file mode 100644 index 000000000..9072d10e3 --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-resource_limit_exceeded.json @@ -0,0 +1,13 @@ +{ + "error": { + "code": "resource_limit_exceeded", + "message": "project limit exceeded", + "details": { + "limits": [ + { + "name": "project_limit" + } + ] + } + } +} diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/get_action.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/get_action.json new file mode 100644 index 000000000..19278fc51 --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/get_action.json @@ -0,0 +1,20 @@ +{ + "action": { + "id": 590000000000000, + "command": "start_resource", + "status": "running", + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:55:00+00:00", + "progress": 100, + "resources": [ + { + "id": 590000000000000, + "type": "server" + } + ], + "error": { + "code": "action_failed", + "message": "Action failed" + } + } +} diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/remove_rrset_records-request.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/remove_rrset_records-request.json new file mode 100644 index 000000000..778e051b4 --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/remove_rrset_records-request.json @@ -0,0 +1,8 @@ +{ + "records": [ + { + "value": "198.51.100.1", + "comment": "My web server at Hetzner Cloud." + } + ] +} diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/remove_rrset_records.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/remove_rrset_records.json new file mode 100644 index 000000000..1b10dfd5e --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/remove_rrset_records.json @@ -0,0 +1,17 @@ +{ + "action": { + "id": 1, + "command": "remove_rrset_records", + "status": "running", + "progress": 50, + "started": "2016-01-30T23:55:00+00:00", + "finished": null, + "resources": [ + { + "id": 42, + "type": "zone" + } + ], + "error": null + } +} diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/types.go b/providers/dns/hetzner/internal/hetznerv1/internal/types.go new file mode 100644 index 000000000..2b38a8a8c --- /dev/null +++ b/providers/dns/hetzner/internal/hetznerv1/internal/types.go @@ -0,0 +1,98 @@ +package internal + +import ( + "fmt" + "strings" +) + +type APIError struct { + ErrorInfo ErrorInfo `json:"error"` +} + +type ErrorInfo struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Details ErrorDetails `json:"details"` +} + +func (i *ErrorInfo) Error() string { + msg := new(strings.Builder) + + _, _ = fmt.Fprintf(msg, "%s: %s", i.Code, i.Message) + + if i.Details.Announcement != "" { + _, _ = fmt.Fprintf(msg, ": %s", i.Details.Announcement) + } + + for _, limit := range i.Details.Limits { + _, _ = fmt.Fprintf(msg, "limit: %s", limit.Name) + } + + for _, field := range i.Details.Fields { + _, _ = fmt.Fprintf(msg, "field: %s: %s", field.Name, strings.Join(field.Messages, ", ")) + } + + return msg.String() +} + +type ErrorDetails struct { + Announcement string `json:"announcement,omitempty"` + Limits []LimitError `json:"limits,omitempty"` + Fields []FieldError `json:"fields,omitempty"` +} + +type FieldError struct { + Name string `json:"name,omitempty"` + Messages []string `json:"messages,omitempty"` +} + +type LimitError struct { + Name string `json:"name,omitempty"` +} + +func (a *APIError) Error() string { + return a.ErrorInfo.Error() +} + +type RRSet struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + TTL int `json:"ttl,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Protection *Protection `json:"protection,omitempty"` + Records []Record `json:"records,omitempty"` + ZoneID int `json:"zone,omitempty"` +} + +type Protection struct { + Change bool `json:"change,omitempty"` +} + +type Record struct { + Value string `json:"value,omitempty"` + Comment string `json:"comment,omitempty"` +} + +type ActionResponse struct { + Action *Action `json:"action,omitempty"` +} + +type Action struct { + ID int64 `json:"id,omitempty"` + Command string `json:"command,omitempty"` + + // It can be: `running`, `success`, `error`. + // https://docs.hetzner.cloud/reference/cloud#zone-actions-get-an-action + // https://docs.hetzner.cloud/reference/cloud#zone-actions + Status string `json:"status,omitempty"` + Progress int `json:"progress,omitempty"` + + Resources []Resources `json:"resources,omitempty"` + ErrorInfo *ErrorInfo `json:"error,omitempty"` +} + +type Resources struct { + ID int64 `json:"id,omitempty"` + Type string `json:"type,omitempty"` +} diff --git a/providers/dns/hetzner/internal/legacy/hetzner.go b/providers/dns/hetzner/internal/legacy/hetzner.go new file mode 100644 index 000000000..393a3d671 --- /dev/null +++ b/providers/dns/hetzner/internal/legacy/hetzner.go @@ -0,0 +1,177 @@ +// Package legacy implements a DNS provider for solving the DNS-01 challenge using Hetzner DNS. +package legacy + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/legacy/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "HETZNER_" + + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +const minTTL = 60 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, minTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for hetzner. +// Credentials must be passed in the environment variable: HETZNER_API_KEY. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("hetzner (legacy): %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for hetzner. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("hetzner (legacy): the configuration of the DNS provider is nil") + } + + if config.APIKey == "" { + return nil, errors.New("hetzner (legacy): credentials missing") + } + + if config.TTL < minTTL { + return nil, fmt.Errorf("hetzner (legacy): invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) + } + + client := internal.NewClient(config.APIKey) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{config: config, client: client}, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("hetzner (legacy): could not find zone for domain %q: %w", domain, err) + } + + zone := dns01.UnFqdn(authZone) + + ctx := context.Background() + + zoneID, err := d.client.GetZoneID(ctx, zone) + if err != nil { + return fmt.Errorf("hetzner (legacy): %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) + if err != nil { + return fmt.Errorf("hetzner (legacy): %w", err) + } + + record := internal.DNSRecord{ + Type: "TXT", + Name: subDomain, + Value: info.Value, + TTL: d.config.TTL, + ZoneID: zoneID, + } + + if err := d.client.CreateRecord(ctx, record); err != nil { + return fmt.Errorf("hetzner (legacy): failed to add TXT record: fqdn=%s, zoneID=%s: %w", info.EffectiveFQDN, zoneID, err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("hetzner (legacy): could not find zone for domain %q: %w", domain, err) + } + + zone := dns01.UnFqdn(authZone) + + ctx := context.Background() + + zoneID, err := d.client.GetZoneID(ctx, zone) + if err != nil { + return fmt.Errorf("hetzner (legacy): %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) + if err != nil { + return fmt.Errorf("hetzner (legacy): %w", err) + } + + record, err := d.client.GetTxtRecord(ctx, subDomain, info.Value, zoneID) + if err != nil { + return fmt.Errorf("hetzner (legacy): %w", err) + } + + if err := d.client.DeleteRecord(ctx, record.ID); err != nil { + return fmt.Errorf("hetzner (legacy): failed to delete TXT record: id=%s, name=%s: %w", record.ID, record.Name, err) + } + + return nil +} diff --git a/providers/dns/hetzner/internal/legacy/hetzner_test.go b/providers/dns/hetzner/internal/legacy/hetzner_test.go new file mode 100644 index 000000000..c9258ecf8 --- /dev/null +++ b/providers/dns/hetzner/internal/legacy/hetzner_test.go @@ -0,0 +1,128 @@ +package legacy + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvAPIKey). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "123", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvAPIKey: "", + }, + expected: "hetzner (legacy): some credentials information are missing: HETZNER_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + ttl int + expected string + }{ + { + desc: "success", + ttl: minTTL, + apiKey: "123", + }, + { + desc: "missing credentials", + ttl: minTTL, + expected: "hetzner (legacy): credentials missing", + }, + { + desc: "invalid TTL", + apiKey: "123", + ttl: 10, + expected: "hetzner (legacy): invalid TTL, TTL (10) must be greater than 60", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + config.TTL = test.ttl + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/hetzner/internal/client.go b/providers/dns/hetzner/internal/legacy/internal/client.go similarity index 99% rename from providers/dns/hetzner/internal/client.go rename to providers/dns/hetzner/internal/legacy/internal/client.go index 381922264..cd187f6e5 100644 --- a/providers/dns/hetzner/internal/client.go +++ b/providers/dns/hetzner/internal/legacy/internal/client.go @@ -83,6 +83,7 @@ func (c *Client) getRecords(ctx context.Context, zoneID string) (*DNSRecords, er } records := &DNSRecords{} + err = json.Unmarshal(raw, records) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) @@ -190,6 +191,7 @@ func (c *Client) getZones(ctx context.Context, name string) (*Zones, error) { } zones := &Zones{} + err = json.Unmarshal(raw, zones) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) diff --git a/providers/dns/hetzner/internal/legacy/internal/client_test.go b/providers/dns/hetzner/internal/legacy/internal/client_test.go new file mode 100644 index 000000000..ade312a90 --- /dev/null +++ b/providers/dns/hetzner/internal/legacy/internal/client_test.go @@ -0,0 +1,89 @@ +package internal + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder(apiKey string) *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(apiKey) + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With(authHeader, apiKey)) +} + +func TestClient_GetTxtRecord(t *testing.T) { + const zoneID = "zoneA" + + client := mockBuilder("myKeyA"). + Route("GET /api/v1/records", servermock.ResponseFromFixture("get_txt_record.json"), + servermock.CheckQueryParameter().Strict(). + With("zone_id", zoneID)). + Build(t) + + record, err := client.GetTxtRecord(t.Context(), "test1", "txttxttxt", zoneID) + require.NoError(t, err) + + expected := &DNSRecord{ + ID: "1b", + Name: "test1", + Type: "TXT", + Value: "txttxttxt", + Priority: 0, + TTL: 600, + ZoneID: "zoneA", + } + + assert.Equal(t, expected, record) +} + +func TestClient_CreateRecord(t *testing.T) { + const zoneID = "zoneA" + + client := mockBuilder("myKeyB"). + Route("POST /api/v1/records", servermock.ResponseFromFixture("create_txt_record.json"), + servermock.CheckRequestJSONBodyFromFixture("create_txt_record-request.json")). + Build(t) + + record := DNSRecord{ + Name: "test", + Type: "TXT", + Value: "txttxttxt", + TTL: 600, + ZoneID: zoneID, + } + + err := client.CreateRecord(t.Context(), record) + require.NoError(t, err) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder("myKeyC"). + Route("DELETE /api/v1/records/recordID", nil). + Build(t) + + err := client.DeleteRecord(t.Context(), "recordID") + require.NoError(t, err) +} + +func TestClient_GetZoneID(t *testing.T) { + client := mockBuilder("myKeyD"). + Route("GET /api/v1/zones", servermock.ResponseFromFixture("get_zone_id.json")). + Build(t) + + zoneID, err := client.GetZoneID(t.Context(), "example.com") + require.NoError(t, err) + + assert.Equal(t, "zoneA", zoneID) +} diff --git a/providers/dns/hetzner/internal/legacy/internal/fixtures/create_txt_record-request.json b/providers/dns/hetzner/internal/legacy/internal/fixtures/create_txt_record-request.json new file mode 100644 index 000000000..894d81886 --- /dev/null +++ b/providers/dns/hetzner/internal/legacy/internal/fixtures/create_txt_record-request.json @@ -0,0 +1,7 @@ +{ + "name": "test", + "type": "TXT", + "value": "txttxttxt", + "ttl": 600, + "zone_id": "zoneA" +} diff --git a/providers/dns/hetzner/internal/fixtures/create_txt_record.json b/providers/dns/hetzner/internal/legacy/internal/fixtures/create_txt_record.json similarity index 100% rename from providers/dns/hetzner/internal/fixtures/create_txt_record.json rename to providers/dns/hetzner/internal/legacy/internal/fixtures/create_txt_record.json diff --git a/providers/dns/hetzner/internal/fixtures/get_txt_record.json b/providers/dns/hetzner/internal/legacy/internal/fixtures/get_txt_record.json similarity index 100% rename from providers/dns/hetzner/internal/fixtures/get_txt_record.json rename to providers/dns/hetzner/internal/legacy/internal/fixtures/get_txt_record.json diff --git a/providers/dns/hetzner/internal/fixtures/get_zone_id.json b/providers/dns/hetzner/internal/legacy/internal/fixtures/get_zone_id.json similarity index 100% rename from providers/dns/hetzner/internal/fixtures/get_zone_id.json rename to providers/dns/hetzner/internal/legacy/internal/fixtures/get_zone_id.json diff --git a/providers/dns/hetzner/internal/types.go b/providers/dns/hetzner/internal/legacy/internal/types.go similarity index 91% rename from providers/dns/hetzner/internal/types.go rename to providers/dns/hetzner/internal/legacy/internal/types.go index d0e284511..3b332cc8f 100644 --- a/providers/dns/hetzner/internal/types.go +++ b/providers/dns/hetzner/internal/legacy/internal/types.go @@ -25,12 +25,12 @@ type Zone struct { // Zones a set of DNS zones. type Zones struct { Zones []Zone `json:"zones"` - Meta Meta `json:"meta,omitempty"` + Meta Meta `json:"meta"` } // Meta response metadata. type Meta struct { - Pagination Pagination `json:"pagination,omitempty"` + Pagination Pagination `json:"pagination"` } // Pagination information about pagination. diff --git a/providers/dns/hostingde/hostingde.go b/providers/dns/hostingde/hostingde.go index 3b63bbfbe..1e022b630 100644 --- a/providers/dns/hostingde/hostingde.go +++ b/providers/dns/hostingde/hostingde.go @@ -2,13 +2,12 @@ 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/hostingde" @@ -27,15 +26,10 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +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 { @@ -43,7 +37,7 @@ func NewDefaultConfig() *Config { ZoneName: env.GetOrFile(EnvZoneName), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, @@ -52,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. @@ -80,140 +70,36 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("hostingde: the configuration of the DNS provider is nil") } - if config.APIKey == "" { - return nil, errors.New("hostingde: API key missing") + provider, err := hostingde.NewDNSProviderConfig(config, "") + if err != nil { + return nil, fmt.Errorf("hostingde: %w", err) } - return &DNSProvider{ - config: config, - client: hostingde.NewClient(config.APIKey), - recordIDs: make(map[string]string), - }, nil + return &DNSProvider{prv: provider}, nil } -// Timeout returns the timeout and interval to use when checking for DNS propagation. -// Adjusting here to cope with spikes in propagation times. -func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.config.PropagationTimeout, d.config.PollingInterval -} - -// Present creates a TXT record to fulfill the dns-01 challenge. +// Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - zoneName, err := d.getZoneName(info.EffectiveFQDN) - if err != nil { - return fmt.Errorf("hostingde: could not find zone for domain %q: %w", domain, err) - } - - ctx := context.Background() - - // get the ZoneConfig for that domain - zonesFind := hostingde.ZoneConfigsFindRequest{ - Filter: hostingde.Filter{Field: "zoneName", Value: zoneName}, - Limit: 1, - Page: 1, - } - - zoneConfig, err := d.client.GetZone(ctx, zonesFind) + err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("hostingde: %w", err) } - zoneConfig.Name = zoneName - - rec := []hostingde.DNSRecord{{ - Type: "TXT", - Name: dns01.UnFqdn(info.EffectiveFQDN), - Content: info.Value, - TTL: d.config.TTL, - }} - - req := hostingde.ZoneUpdateRequest{ - ZoneConfig: *zoneConfig, - RecordsToAdd: rec, - } - - response, err := d.client.UpdateZone(ctx, req) - if err != nil { - return fmt.Errorf("hostingde: %w", err) - } - - for _, record := range response.Records { - if record.Name == dns01.UnFqdn(info.EffectiveFQDN) && record.Content == fmt.Sprintf(`%q`, info.Value) { - d.recordIDsMu.Lock() - d.recordIDs[info.EffectiveFQDN] = record.ID - d.recordIDsMu.Unlock() - } - } - - if d.recordIDs[info.EffectiveFQDN] == "" { - return fmt.Errorf("hostingde: error getting ID of just created record, for domain %s", domain) - } - return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - zoneName, err := d.getZoneName(info.EffectiveFQDN) - if err != nil { - return fmt.Errorf("hostingde: could not find zone for domain %q: %w", domain, err) - } - - ctx := context.Background() - - // get the ZoneConfig for that domain - zonesFind := hostingde.ZoneConfigsFindRequest{ - Filter: hostingde.Filter{Field: "zoneName", Value: zoneName}, - Limit: 1, - Page: 1, - } - - zoneConfig, err := d.client.GetZone(ctx, zonesFind) + err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("hostingde: %w", err) } - zoneConfig.Name = zoneName - rec := []hostingde.DNSRecord{{ - Type: "TXT", - Name: dns01.UnFqdn(info.EffectiveFQDN), - Content: `"` + info.Value + `"`, - }} - - req := hostingde.ZoneUpdateRequest{ - ZoneConfig: *zoneConfig, - RecordsToDelete: rec, - } - - // Delete record ID from map - d.recordIDsMu.Lock() - delete(d.recordIDs, info.EffectiveFQDN) - d.recordIDsMu.Unlock() - - _, err = d.client.UpdateZone(ctx, req) - if err != nil { - return fmt.Errorf("hostingde: %w", err) - } return nil } -func (d *DNSProvider) getZoneName(fqdn string) (string, error) { - if d.config.ZoneName != "" { - return d.config.ZoneName, nil - } - - zoneName, err := dns01.FindZoneByFqdn(fqdn) - if err != nil { - return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err) - } - - if zoneName == "" { - return "", errors.New("empty zone name") - } - - return dns01.UnFqdn(zoneName), nil +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.prv.Timeout() } diff --git a/providers/dns/hostingde/hostingde.toml b/providers/dns/hostingde/hostingde.toml index 39e7ab0f9..502a7fe9e 100644 --- a/providers/dns/hostingde/hostingde.toml +++ b/providers/dns/hostingde/hostingde.toml @@ -6,7 +6,7 @@ Since = "v1.1.0" Example = ''' HOSTINGDE_API_KEY=xxxxxxxx \ -lego --email you@example.com --dns hostingde -d '*.example.com' -d example.com run +lego --dns hostingde -d '*.example.com' -d example.com run ''' [Configuration] @@ -14,10 +14,10 @@ lego --email you@example.com --dns hostingde -d '*.example.com' -d example.com r HOSTINGDE_API_KEY = "API key" [Configuration.Additional] HOSTINGDE_ZONE_NAME = "Zone name in ACE format" - HOSTINGDE_POLLING_INTERVAL = "Time between DNS propagation check" - HOSTINGDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - HOSTINGDE_TTL = "The TTL of the TXT record used for the DNS challenge" - HOSTINGDE_HTTP_TIMEOUT = "API request timeout" + HOSTINGDE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + HOSTINGDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + HOSTINGDE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + HOSTINGDE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.hosting.de/api/#dns" diff --git a/providers/dns/hostingde/hostingde_test.go b/providers/dns/hostingde/hostingde_test.go index d7681f953..a92006f81 100644 --- a/providers/dns/hostingde/hostingde_test.go +++ b/providers/dns/hostingde/hostingde_test.go @@ -49,6 +49,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -58,8 +59,7 @@ func TestNewDNSProvider(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - require.NotNil(t, p.config) - require.NotNil(t, p.recordIDs) + require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -101,8 +101,7 @@ func TestNewDNSProviderConfig(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - require.NotNil(t, p.config) - require.NotNil(t, p.recordIDs) + require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -116,6 +115,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -129,6 +129,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/hostinger/hostinger.go b/providers/dns/hostinger/hostinger.go new file mode 100644 index 000000000..13d9ed0f8 --- /dev/null +++ b/providers/dns/hostinger/hostinger.go @@ -0,0 +1,211 @@ +// Package hostinger implements a DNS provider for solving the DNS-01 challenge using Hostinger. +package hostinger + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/hostinger/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "HOSTINGER_" + + EnvAPIToken = envNamespace + "API_TOKEN" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIToken string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Hostinger. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIToken) + if err != nil { + return nil, fmt.Errorf("hostinger: %w", err) + } + + config := NewDefaultConfig() + config.APIToken = values[EnvAPIToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Hostinger. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("hostinger: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.APIToken) + if err != nil { + return nil, fmt.Errorf("hostinger: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("hostinger: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("hostinger: %w", err) + } + + ctx := context.Background() + + request := internal.ZoneRequest{ + Overwrite: false, + Zone: []internal.RecordSet{{ + Name: subDomain, + Type: "TXT", + TTL: d.config.TTL, + Records: []internal.Record{ + {Content: info.Value}, + }, + }}, + } + + err = d.client.UpdateDNSRecords(ctx, dns01.UnFqdn(authZone), request) + if err != nil { + return fmt.Errorf("hostinger: update DNS records (add): %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("hostinger: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("hostinger: %w", err) + } + + ctx := context.Background() + + recordSet, err := d.findRecordSet(ctx, authZone, subDomain) + if err != nil { + return fmt.Errorf("hostinger: %w", err) + } + + var newRecords []internal.Record + + for _, record := range recordSet.Records { + if record.Content == info.Value || record.Content == strconv.Quote(info.Value) { + continue + } + + newRecords = append(newRecords, record) + } + + recordSet.Records = newRecords + + if len(recordSet.Records) > 0 { + request := internal.ZoneRequest{ + Overwrite: true, + Zone: []internal.RecordSet{recordSet}, + } + + err = d.client.UpdateDNSRecords(ctx, dns01.UnFqdn(authZone), request) + if err != nil { + return fmt.Errorf("hostinger: update DNS records (delete): %w", err) + } + + return nil + } + + filters := []internal.Filter{{ + Name: subDomain, + Type: "TXT", + }} + + err = d.client.DeleteDNSRecords(ctx, dns01.UnFqdn(authZone), filters) + if err != nil { + return fmt.Errorf("hostinger: delete DNS records: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) findRecordSet(ctx context.Context, authZone, subDomain string) (internal.RecordSet, error) { + recordSets, err := d.client.GetDNSRecords(ctx, dns01.UnFqdn(authZone)) + if err != nil { + return internal.RecordSet{}, fmt.Errorf("get DNS records: %w", err) + } + + for _, recordSet := range recordSets { + if recordSet.Name != subDomain || recordSet.Type != "TXT" { + continue + } + + return recordSet, nil + } + + return internal.RecordSet{}, fmt.Errorf("no record found for domain %q and subdomain %q", authZone, subDomain) +} diff --git a/providers/dns/hostinger/hostinger.toml b/providers/dns/hostinger/hostinger.toml new file mode 100644 index 000000000..a6f152e73 --- /dev/null +++ b/providers/dns/hostinger/hostinger.toml @@ -0,0 +1,22 @@ +Name = "Hostinger" +Description = '''''' +URL = "https://www.hostinger.com/" +Code = "hostinger" +Since = "v4.27.0" + +Example = ''' +HOSTINGER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns hostinger -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + HOSTINGER_API_TOKEN = "API Token" + [Configuration.Additional] + HOSTINGER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + HOSTINGER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + HOSTINGER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + HOSTINGER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://developers.hostinger.com/#tag/dns-zone" diff --git a/providers/dns/hostinger/hostinger_test.go b/providers/dns/hostinger/hostinger_test.go new file mode 100644 index 000000000..90ecba529 --- /dev/null +++ b/providers/dns/hostinger/hostinger_test.go @@ -0,0 +1,180 @@ +package hostinger + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIToken).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIToken: "secret", + }, + }, + { + desc: "missing API token", + envVars: map[string]string{ + EnvAPIToken: "", + }, + expected: "hostinger: some credentials information are missing: HOSTINGER_API_TOKEN", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiToken string + expected string + }{ + { + desc: "success", + apiToken: "secret", + }, + { + desc: "missing API token", + expected: "hostinger: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIToken = test.apiToken + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIToken = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + WithAuthorization("Bearer secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("PUT /api/dns/v1/zones/example.com", + servermock.ResponseFromInternal("update_dns_records.json"), + servermock.CheckRequestJSONBodyFromInternal("update_dns_records-request.json")). + Build(t) + + err := provider.Present("example.com", "", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp_update(t *testing.T) { + provider := mockBuilder(). + Route("GET /api/dns/v1/zones/example.com", + servermock.ResponseFromInternal("get_dns_records_acme.json")). + Route("PUT /api/dns/v1/zones/example.com", + servermock.ResponseFromInternal("update_dns_records.json"), + servermock.CheckRequestJSONBodyFromInternal("update_dns_records_base-request.json")). + Build(t) + + err := provider.CleanUp("example.com", "", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp_delete(t *testing.T) { + provider := mockBuilder(). + Route("GET /api/dns/v1/zones/example.com", + servermock.ResponseFromInternal("get_dns_records_empty.json")). + Route("DELETE /api/dns/v1/zones/example.com", + servermock.ResponseFromInternal("delete_dns_records.json"), + servermock.CheckRequestJSONBody(`{"filters":[{"name":"_acme-challenge","type":"TXT"}]}`)). + Build(t) + + err := provider.CleanUp("example.com", "", "123d==") + require.NoError(t, err) +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/hostinger/internal/client.go b/providers/dns/hostinger/internal/client.go new file mode 100644 index 000000000..9da712d61 --- /dev/null +++ b/providers/dns/hostinger/internal/client.go @@ -0,0 +1,156 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const defaultBaseURL = "https://developers.hostinger.com" + +const authorizationHeader = "Authorization" + +// Client the Hostinger API client. +type Client struct { + token string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(token string) (*Client, error) { + if token == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + token: token, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// GetDNSRecords retrieves DNS zone records for a specific domain. +// https://developers.hostinger.com/#tag/dns-zone/get/api/dns/v1/zones/{domain} +func (c *Client) GetDNSRecords(ctx context.Context, domain string) ([]RecordSet, error) { + endpoint := c.BaseURL.JoinPath("/api/dns/v1/zones/", domain) + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result []RecordSet + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result, nil +} + +// UpdateDNSRecords updates DNS records for the selected domain. +// https://developers.hostinger.com/#tag/dns-zone/put/api/dns/v1/zones/{domain} +func (c *Client) UpdateDNSRecords(ctx context.Context, domain string, zone ZoneRequest) error { + endpoint := c.BaseURL.JoinPath("/api/dns/v1/zones/", domain) + + req, err := newJSONRequest(ctx, http.MethodPut, endpoint, zone) + if err != nil { + return err + } + + return c.do(req, nil) +} + +// DeleteDNSRecords deletes DNS records for the selected domain. +// https://developers.hostinger.com/#tag/dns-zone/delete/api/dns/v1/zones/{domain} +func (c *Client) DeleteDNSRecords(ctx context.Context, domain string, filters []Filter) error { + endpoint := c.BaseURL.JoinPath("/api/dns/v1/zones/", domain) + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, Filters{Filters: filters}) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) do(req *http.Request, result any) error { + req.Header.Set(authorizationHeader, "Bearer "+c.token) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/hostinger/internal/client_test.go b/providers/dns/hostinger/internal/client_test.go new file mode 100644 index 000000000..69cab5587 --- /dev/null +++ b/providers/dns/hostinger/internal/client_test.go @@ -0,0 +1,154 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With("Authorization", "Bearer secret"), + ) +} + +func TestClient_GetDNSRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /api/dns/v1/zones/example.com", + servermock.ResponseFromFixture("get_dns_records.json")). + Build(t) + + records, err := client.GetDNSRecords(t.Context(), "example.com") + require.NoError(t, err) + + expected := []RecordSet{ + { + Name: "_acme-challenge", + Records: []Record{{ + Content: "aaa", + }}, + TTL: 14400, + Type: "TXT", + }, + { + Name: "_acme-challenge", + Records: []Record{{ + Content: "example.com.", + }}, + TTL: 14400, + Type: "A", + }, + } + + assert.Equal(t, expected, records) +} + +func TestClient_GetDNSRecords_error(t *testing.T) { + client := mockBuilder(). + Route("GET /api/dns/v1/zones/example.com", + servermock.ResponseFromFixture("error_401.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + _, err := client.GetDNSRecords(t.Context(), "example.com") + + require.EqualError(t, err, "26a91bd9-f8c8-4a83-9df9-83e23d696fe3: Unauthenticated") +} + +func TestClient_UpdateDNSRecords(t *testing.T) { + client := mockBuilder(). + Route("PUT /api/dns/v1/zones/example.com", + servermock.ResponseFromFixture("update_dns_records.json"), + servermock.CheckRequestJSONBodyFromFixture("update_dns_records-request.json")). + Build(t) + + zone := ZoneRequest{ + Overwrite: false, + Zone: []RecordSet{ + { + Name: "_acme-challenge", + Records: []Record{ + {Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, + }, + TTL: 120, + Type: "TXT", + }, + }, + } + + err := client.UpdateDNSRecords(t.Context(), "example.com", zone) + require.NoError(t, err) +} + +func TestClient_UpdateDNSRecords_error(t *testing.T) { + client := mockBuilder(). + Route("PUT /api/dns/v1/zones/example.com", + servermock.ResponseFromFixture("error_422.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + zone := ZoneRequest{ + Zone: []RecordSet{{ + Name: "_acme-challenge", + Records: []Record{{ + Content: "aaa", + }}, + TTL: 14400, + Type: "TXT", + }}, + } + + err := client.UpdateDNSRecords(t.Context(), "example.com", zone) + + require.EqualError(t, err, "26a91bd9-f8c8-4a83-9df9-83e23d696fe3: The name field is required. (and 1 more error): field_1: The field_1 field is required., The field_1 must be a number.") +} + +func TestClient_DeleteDNSRecords(t *testing.T) { + client := mockBuilder(). + Route("DELETE /api/dns/v1/zones/example.com", + servermock.ResponseFromFixture("delete_dns_records.json"), + servermock.CheckRequestJSONBody(`{"filters":[{"name":"_acme-challenge","type":"TXT"}]}`)). + Build(t) + + filters := []Filter{{ + Name: "_acme-challenge", + Type: "TXT", + }} + + err := client.DeleteDNSRecords(t.Context(), "example.com", filters) + require.NoError(t, err) +} + +func TestClient_DeleteDNSRecords_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /api/dns/v1/zones/example.com", + servermock.ResponseFromFixture("error_401.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + filters := []Filter{{ + Name: "_acme-challenge", + Type: "TXT", + }} + + err := client.DeleteDNSRecords(t.Context(), "example.com", filters) + + require.EqualError(t, err, "26a91bd9-f8c8-4a83-9df9-83e23d696fe3: Unauthenticated") +} diff --git a/providers/dns/hostinger/internal/fixtures/delete_dns_records.json b/providers/dns/hostinger/internal/fixtures/delete_dns_records.json new file mode 100644 index 000000000..11d2582b4 --- /dev/null +++ b/providers/dns/hostinger/internal/fixtures/delete_dns_records.json @@ -0,0 +1,3 @@ +{ + "message": "Request accepted" +} diff --git a/providers/dns/hostinger/internal/fixtures/error_401.json b/providers/dns/hostinger/internal/fixtures/error_401.json new file mode 100644 index 000000000..1b7381ff6 --- /dev/null +++ b/providers/dns/hostinger/internal/fixtures/error_401.json @@ -0,0 +1,4 @@ +{ + "message": "Unauthenticated", + "correlation_id": "26a91bd9-f8c8-4a83-9df9-83e23d696fe3" +} diff --git a/providers/dns/hostinger/internal/fixtures/error_422.json b/providers/dns/hostinger/internal/fixtures/error_422.json new file mode 100644 index 000000000..6ec286823 --- /dev/null +++ b/providers/dns/hostinger/internal/fixtures/error_422.json @@ -0,0 +1,10 @@ +{ + "message": "The name field is required. (and 1 more error)", + "errors": { + "field_1": [ + "The field_1 field is required.", + "The field_1 must be a number." + ] + }, + "correlation_id": "26a91bd9-f8c8-4a83-9df9-83e23d696fe3" +} diff --git a/providers/dns/hostinger/internal/fixtures/get_dns_records.json b/providers/dns/hostinger/internal/fixtures/get_dns_records.json new file mode 100644 index 000000000..e51edd4dc --- /dev/null +++ b/providers/dns/hostinger/internal/fixtures/get_dns_records.json @@ -0,0 +1,24 @@ +[ + { + "name": "_acme-challenge", + "records": [ + { + "content": "aaa", + "is_disabled": false + } + ], + "ttl": 14400, + "type": "TXT" + }, + { + "name": "_acme-challenge", + "records": [ + { + "content": "example.com.", + "is_disabled": false + } + ], + "ttl": 14400, + "type": "A" + } +] diff --git a/providers/dns/hostinger/internal/fixtures/get_dns_records_acme.json b/providers/dns/hostinger/internal/fixtures/get_dns_records_acme.json new file mode 100644 index 000000000..99a574514 --- /dev/null +++ b/providers/dns/hostinger/internal/fixtures/get_dns_records_acme.json @@ -0,0 +1,27 @@ +[ + { + "name": "_acme-challenge", + "records": [ + { + "content": "aaa", + "is_disabled": false + }, + { + "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + } + ], + "ttl": 14400, + "type": "TXT" + }, + { + "name": "_acme-challenge", + "records": [ + { + "content": "example.com.", + "is_disabled": false + } + ], + "ttl": 14400, + "type": "A" + } +] diff --git a/providers/dns/hostinger/internal/fixtures/get_dns_records_empty.json b/providers/dns/hostinger/internal/fixtures/get_dns_records_empty.json new file mode 100644 index 000000000..9989a3fc4 --- /dev/null +++ b/providers/dns/hostinger/internal/fixtures/get_dns_records_empty.json @@ -0,0 +1,23 @@ +[ + { + "name": "_acme-challenge", + "records": [ + { + "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + } + ], + "ttl": 14400, + "type": "TXT" + }, + { + "name": "_acme-challenge", + "records": [ + { + "content": "example.com.", + "is_disabled": false + } + ], + "ttl": 14400, + "type": "A" + } +] diff --git a/providers/dns/hostinger/internal/fixtures/update_dns_records-request.json b/providers/dns/hostinger/internal/fixtures/update_dns_records-request.json new file mode 100644 index 000000000..6f287b3fc --- /dev/null +++ b/providers/dns/hostinger/internal/fixtures/update_dns_records-request.json @@ -0,0 +1,15 @@ +{ + "overwrite": false, + "zone": [ + { + "name": "_acme-challenge", + "records": [ + { + "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + } + ], + "ttl": 120, + "type": "TXT" + } + ] +} diff --git a/providers/dns/hostinger/internal/fixtures/update_dns_records.json b/providers/dns/hostinger/internal/fixtures/update_dns_records.json new file mode 100644 index 000000000..11d2582b4 --- /dev/null +++ b/providers/dns/hostinger/internal/fixtures/update_dns_records.json @@ -0,0 +1,3 @@ +{ + "message": "Request accepted" +} diff --git a/providers/dns/hostinger/internal/fixtures/update_dns_records_base-request.json b/providers/dns/hostinger/internal/fixtures/update_dns_records_base-request.json new file mode 100644 index 000000000..c42ddc6d7 --- /dev/null +++ b/providers/dns/hostinger/internal/fixtures/update_dns_records_base-request.json @@ -0,0 +1,15 @@ +{ + "overwrite": true, + "zone": [ + { + "name": "_acme-challenge", + "records": [ + { + "content": "aaa" + } + ], + "ttl": 14400, + "type": "TXT" + } + ] +} diff --git a/providers/dns/hostinger/internal/types.go b/providers/dns/hostinger/internal/types.go new file mode 100644 index 000000000..c1a02ff8c --- /dev/null +++ b/providers/dns/hostinger/internal/types.go @@ -0,0 +1,50 @@ +package internal + +import ( + "fmt" + "strings" +) + +type APIError struct { + Message string `json:"message,omitempty"` + Errors map[string][]string `json:"errors,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` +} + +func (a *APIError) Error() string { + msg := new(strings.Builder) + + _, _ = fmt.Fprintf(msg, "%s: %s", a.CorrelationID, a.Message) + + for field, values := range a.Errors { + _, _ = fmt.Fprintf(msg, ": %s: %s", field, strings.Join(values, ", ")) + } + + return msg.String() +} + +type ZoneRequest struct { + Overwrite bool `json:"overwrite"` + Zone []RecordSet `json:"zone,omitempty"` +} + +type RecordSet struct { + Name string `json:"name,omitempty"` + Records []Record `json:"records,omitempty"` + TTL int `json:"ttl,omitempty"` + Type string `json:"type,omitempty"` +} + +type Record struct { + Content string `json:"content,omitempty"` + IsDisabled bool `json:"is_disabled,omitempty"` +} + +type Filters struct { + Filters []Filter `json:"filters"` +} + +type Filter struct { + Name string `json:"name"` + Type string `json:"type"` +} diff --git a/providers/dns/hostingnl/hostingnl.go b/providers/dns/hostingnl/hostingnl.go new file mode 100644 index 000000000..a49941817 --- /dev/null +++ b/providers/dns/hostingnl/hostingnl.go @@ -0,0 +1,168 @@ +// Package hostingnl implements a DNS provider for solving the DNS-01 challenge using hosting.nl. +package hostingnl + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/hostingnl/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "HOSTINGNL_" + + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIKey string + HTTPClient *http.Client + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + recordIDs map[string]string + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for hosting.nl. +// Credentials must be passed in the environment variables: +// HOSTINGNL_APIKEY. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("hostingnl: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for hosting.nl. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("hostingnl: the configuration of the DNS provider is nil") + } + + if config.APIKey == "" { + return nil, errors.New("hostingnl: APIKey is missing") + } + + client := internal.NewClient(config.APIKey) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]string), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("hostingnl: could not find zone for domain %q: %w", domain, err) + } + + record := internal.Record{ + Name: dns01.UnFqdn(info.EffectiveFQDN), + Type: "TXT", + Content: strconv.Quote(info.Value), + TTL: d.config.TTL, + Priority: 0, + } + + newRecord, err := d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record) + if err != nil { + return fmt.Errorf("hostingnl: failed to create TXT record, fqdn=%s: %w", info.EffectiveFQDN, err) + } + + d.recordIDsMu.Lock() + d.recordIDs[token] = newRecord.ID + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT records matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("hostingnl: could not find zone for domain %q: %w", domain, err) + } + + // gets the record's unique ID + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + + if !ok { + return fmt.Errorf("hostingnl: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID) + if err != nil { + return fmt.Errorf("hostingnl: failed to delete TXT record, id=%s: %w", recordID, err) + } + + // deletes record ID from map + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/hostingnl/hostingnl.toml b/providers/dns/hostingnl/hostingnl.toml new file mode 100644 index 000000000..943264ed3 --- /dev/null +++ b/providers/dns/hostingnl/hostingnl.toml @@ -0,0 +1,22 @@ +Name = "Hosting.nl" +Description = '''''' +URL = "https://hosting.nl" +Code = "hostingnl" +Since = "v4.30.0" + +Example = ''' +HOSTINGNL_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns hostingnl -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + HOSTINGNL_API_KEY = "The API key" + [Configuration.Additional] + HOSTINGNL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + HOSTINGNL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + HOSTINGNL_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + HOSTINGNL_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" + +[Links] + API = "https://api.hosting.nl/api/documentation" diff --git a/providers/dns/hostingnl/hostingnl_test.go b/providers/dns/hostingnl/hostingnl_test.go new file mode 100644 index 000000000..cef754c7c --- /dev/null +++ b/providers/dns/hostingnl/hostingnl_test.go @@ -0,0 +1,167 @@ +package hostingnl + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "key", + }, + }, + { + desc: "missing API key", + envVars: map[string]string{}, + expected: "hostingnl: some credentials information are missing: HOSTINGNL_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + expected string + }{ + { + desc: "success", + apiKey: "key", + }, + { + desc: "missing API key", + expected: "hostingnl: APIKey is missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIKey = "secret" + config.HTTPClient = server.Client() + + provider, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + provider.client.BaseURL, _ = url.Parse(server.URL) + + return provider, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With("API-TOKEN", "secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("POST /domains/example.com/dns", + servermock.ResponseFromInternal("add_record.json"), + servermock.CheckQueryParameter().Strict(), + servermock.CheckRequestJSONBodyFromInternal("add_record-request.json")). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("DELETE /domains/example.com/dns", + servermock.ResponseFromInternal("delete_record.json"), + servermock.CheckQueryParameter().Strict(), + servermock.CheckRequestJSONBodyFromInternal("delete_record-request.json")). + Build(t) + + provider.recordIDs["abc"] = "12345" + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/hostingnl/internal/client.go b/providers/dns/hostingnl/internal/client.go new file mode 100644 index 000000000..f2d7b5346 --- /dev/null +++ b/providers/dns/hostingnl/internal/client.go @@ -0,0 +1,144 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://api.hosting.nl" + +type Client struct { + apiKey string + + BaseURL *url.URL + HTTPClient *http.Client +} + +func NewClient(apiKey string) *Client { + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + apiKey: apiKey, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + } +} + +func (c Client) AddRecord(ctx context.Context, domain string, record Record) (*Record, error) { + endpoint := c.BaseURL.JoinPath("domains", domain, "dns") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, []Record{record}) + if err != nil { + return nil, err + } + + var result APIResponse[Record] + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + if len(result.Data) != 1 { + return nil, fmt.Errorf("unexpected response data: %v", result.Data) + } + + return &result.Data[0], nil +} + +func (c Client) DeleteRecord(ctx context.Context, domain, recordID string) error { + endpoint := c.BaseURL.JoinPath("domains", domain, "dns") + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, []Record{{ID: recordID}}) + if err != nil { + return err + } + + var result APIResponse[Record] + + err = c.do(req, &result) + if err != nil { + return err + } + + return nil +} + +func (c Client) do(req *http.Request, result any) error { + useragent.SetHeader(req.Header) + + req.Header.Set("API-TOKEN", c.apiKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var apiErr APIError + + err := json.Unmarshal(raw, &apiErr) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return fmt.Errorf("[status code: %d] %w", resp.StatusCode, apiErr) +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} diff --git a/providers/dns/hostingnl/internal/client_test.go b/providers/dns/hostingnl/internal/client_test.go new file mode 100644 index 000000000..efdb98980 --- /dev/null +++ b/providers/dns/hostingnl/internal/client_test.go @@ -0,0 +1,92 @@ +package internal + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder( + func(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.HTTPClient = server.Client() + client.BaseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With("API-TOKEN", "secret"), + ) +} + +func TestClient_AddRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/example.com/dns", + servermock.ResponseFromFixture("add_record.json"), + servermock.CheckQueryParameter().Strict(), + servermock.CheckRequestJSONBodyFromFixture("add_record-request.json")). + Build(t) + + record := Record{ + Name: "_acme-challenge.example.com", + Type: "TXT", + Content: strconv.Quote("ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"), + TTL: 120, + } + + newRecord, err := client.AddRecord(context.Background(), "example.com", record) + require.NoError(t, err) + + expected := &Record{ + ID: "12345", + Name: "_acme-challenge.example.com", + Type: "TXT", + Content: strconv.Quote("ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"), + TTL: 120, + } + + assert.Equal(t, expected, newRecord) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domains/example.com/dns", + servermock.ResponseFromFixture("delete_record.json"), + servermock.CheckQueryParameter().Strict(), + servermock.CheckRequestJSONBodyFromFixture("delete_record-request.json")). + Build(t) + + err := client.DeleteRecord(context.Background(), "example.com", "12345") + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domains/example.com/dns", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + err := client.DeleteRecord(context.Background(), "example.com", "12345") + require.EqualError(t, err, "[status code: 401] Something went wrong") +} + +func TestClient_DeleteRecord_error_other(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domains/example.com/dns", + servermock.ResponseFromFixture("error_other.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) + + err := client.DeleteRecord(context.Background(), "example.com", "12345") + require.EqualError(t, err, "[status code: 404] Resource not found") +} diff --git a/providers/dns/hostingnl/internal/fixtures/add_record-request.json b/providers/dns/hostingnl/internal/fixtures/add_record-request.json new file mode 100644 index 000000000..6b68ec3c6 --- /dev/null +++ b/providers/dns/hostingnl/internal/fixtures/add_record-request.json @@ -0,0 +1,8 @@ +[ + { + "name": "_acme-challenge.example.com", + "type": "TXT", + "content": "\"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY\"", + "ttl": 120 + } +] diff --git a/providers/dns/hostingnl/internal/fixtures/add_record.json b/providers/dns/hostingnl/internal/fixtures/add_record.json new file mode 100644 index 000000000..a822a4f8d --- /dev/null +++ b/providers/dns/hostingnl/internal/fixtures/add_record.json @@ -0,0 +1,13 @@ +{ + "success": true, + "data": [ + { + "id": "12345", + "type": "TXT", + "content": "\"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY\"", + "name": "_acme-challenge.example.com", + "prio": 0, + "ttl": 120 + } + ] +} diff --git a/providers/dns/hostingnl/internal/fixtures/delete_record-request.json b/providers/dns/hostingnl/internal/fixtures/delete_record-request.json new file mode 100644 index 000000000..cfc26d2b9 --- /dev/null +++ b/providers/dns/hostingnl/internal/fixtures/delete_record-request.json @@ -0,0 +1,5 @@ +[ + { + "id": "12345" + } +] diff --git a/providers/dns/hostingnl/internal/fixtures/delete_record.json b/providers/dns/hostingnl/internal/fixtures/delete_record.json new file mode 100644 index 000000000..c041c1f6d --- /dev/null +++ b/providers/dns/hostingnl/internal/fixtures/delete_record.json @@ -0,0 +1,8 @@ +{ + "success": true, + "data": [ + { + "id": "12345" + } + ] +} diff --git a/providers/dns/hostingnl/internal/fixtures/error.json b/providers/dns/hostingnl/internal/fixtures/error.json new file mode 100644 index 000000000..170587246 --- /dev/null +++ b/providers/dns/hostingnl/internal/fixtures/error.json @@ -0,0 +1,5 @@ +{ + "errors": { + "message": "Something went wrong" + } +} diff --git a/providers/dns/hostingnl/internal/fixtures/error_other.json b/providers/dns/hostingnl/internal/fixtures/error_other.json new file mode 100644 index 000000000..ca7ecab28 --- /dev/null +++ b/providers/dns/hostingnl/internal/fixtures/error_other.json @@ -0,0 +1,3 @@ +{ + "error": "Resource not found" +} diff --git a/providers/dns/hostingnl/internal/types.go b/providers/dns/hostingnl/internal/types.go new file mode 100644 index 000000000..f324665fe --- /dev/null +++ b/providers/dns/hostingnl/internal/types.go @@ -0,0 +1,36 @@ +package internal + +type Record struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Content string `json:"content,omitempty"` + TTL int `json:"ttl,omitempty"` + Priority int `json:"prio,omitempty"` +} + +type APIResponse[T any] struct { + Success bool `json:"success"` + Data []T `json:"data"` +} + +type APIError struct { + ErrorMsg string `json:"error"` + Errors Error `json:"errors"` +} + +func (e APIError) Error() string { + if e.ErrorMsg != "" { + return e.ErrorMsg + } + + return e.Errors.Error() +} + +type Error struct { + Message string `json:"message"` +} + +func (e Error) Error() string { + return e.Message +} diff --git a/providers/dns/hosttech/hosttech.go b/providers/dns/hosttech/hosttech.go index 94a6a0795..73346f6cb 100644 --- a/providers/dns/hosttech/hosttech.go +++ b/providers/dns/hosttech/hosttech.go @@ -10,9 +10,11 @@ import ( "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/hosttech/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -27,6 +29,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -81,7 +85,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("hosttech: missing credentials") } - client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.APIKey)) + client := internal.NewClient( + clientdebug.Wrap( + internal.OAuthStaticAccessToken(config.HTTPClient, config.APIKey), + ), + ) return &DNSProvider{ config: config, @@ -156,6 +164,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("hosttech: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } @@ -165,5 +174,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("hosttech: %w", err) } + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + return nil } diff --git a/providers/dns/hosttech/hosttech.toml b/providers/dns/hosttech/hosttech.toml index 89d495b0c..52c01fd31 100644 --- a/providers/dns/hosttech/hosttech.toml +++ b/providers/dns/hosttech/hosttech.toml @@ -6,7 +6,7 @@ Since = "v4.5.0" Example = ''' HOSTTECH_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns hosttech -d '*.example.com' -d example.com run +lego --dns hosttech -d '*.example.com' -d example.com run ''' [Configuration] @@ -14,10 +14,10 @@ lego --email you@example.com --dns hosttech -d '*.example.com' -d example.com ru HOSTTECH_API_KEY = "API login" HOSTTECH_PASSWORD = "API password" [Configuration.Additional] - HOSTTECH_POLLING_INTERVAL = "Time between DNS propagation check" - HOSTTECH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - HOSTTECH_TTL = "The TTL of the TXT record used for the DNS challenge" - HOSTTECH_HTTP_TIMEOUT = "API request timeout" + HOSTTECH_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + HOSTTECH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + HOSTTECH_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" + HOSTTECH_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.ns1.hosttech.eu/api/documentation" diff --git a/providers/dns/hosttech/hosttech_test.go b/providers/dns/hosttech/hosttech_test.go index 6f0d0bd3e..042b73353 100644 --- a/providers/dns/hosttech/hosttech_test.go +++ b/providers/dns/hosttech/hosttech_test.go @@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -92,6 +93,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -105,6 +107,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/hosttech/internal/client.go b/providers/dns/hosttech/internal/client.go index 78b594558..557d54298 100644 --- a/providers/dns/hosttech/internal/client.go +++ b/providers/dns/hosttech/internal/client.go @@ -36,7 +36,7 @@ func NewClient(hc *http.Client) *Client { // GetZones Get a list of all zones. // https://api.ns1.hosttech.eu/api/documentation/#/Zones/get_api_user_v1_zones -func (c Client) GetZones(ctx context.Context, query string, limit, offset int) ([]Zone, error) { +func (c *Client) GetZones(ctx context.Context, query string, limit, offset int) ([]Zone, error) { endpoint := c.baseURL.JoinPath("user", "v1", "zones") values := endpoint.Query() @@ -58,6 +58,7 @@ func (c Client) GetZones(ctx context.Context, query string, limit, offset int) ( } result := apiResponse[[]Zone]{} + err = c.do(req, &result) if err != nil { return nil, err @@ -68,7 +69,7 @@ func (c Client) GetZones(ctx context.Context, query string, limit, offset int) ( // GetZone Get a single zone. // https://api.ns1.hosttech.eu/api/documentation/#/Zones/get_api_user_v1_zones__zoneId_ -func (c Client) GetZone(ctx context.Context, zoneID string) (*Zone, error) { +func (c *Client) GetZone(ctx context.Context, zoneID string) (*Zone, error) { endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -77,6 +78,7 @@ func (c Client) GetZone(ctx context.Context, zoneID string) (*Zone, error) { } result := apiResponse[*Zone]{} + err = c.do(req, &result) if err != nil { return nil, err @@ -87,7 +89,7 @@ func (c Client) GetZone(ctx context.Context, zoneID string) (*Zone, error) { // GetRecords Returns a list of all records for the given zone. // https://api.ns1.hosttech.eu/api/documentation/#/Records/get_api_user_v1_zones__zoneId__records -func (c Client) GetRecords(ctx context.Context, zoneID, recordType string) ([]Record, error) { +func (c *Client) GetRecords(ctx context.Context, zoneID, recordType string) ([]Record, error) { endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID, "records") values := endpoint.Query() @@ -104,6 +106,7 @@ func (c Client) GetRecords(ctx context.Context, zoneID, recordType string) ([]Re } result := apiResponse[[]Record]{} + err = c.do(req, &result) if err != nil { return nil, err @@ -114,7 +117,7 @@ func (c Client) GetRecords(ctx context.Context, zoneID, recordType string) ([]Re // AddRecord Adds a new record to the zone and returns the newly created record. // https://api.ns1.hosttech.eu/api/documentation/#/Records/post_api_user_v1_zones__zoneId__records -func (c Client) AddRecord(ctx context.Context, zoneID string, record Record) (*Record, error) { +func (c *Client) AddRecord(ctx context.Context, zoneID string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID, "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) @@ -123,6 +126,7 @@ func (c Client) AddRecord(ctx context.Context, zoneID string, record Record) (*R } result := apiResponse[*Record]{} + err = c.do(req, &result) if err != nil { return nil, err @@ -133,7 +137,7 @@ func (c Client) AddRecord(ctx context.Context, zoneID string, record Record) (*R // DeleteRecord Deletes a single record for the given id. // https://api.ns1.hosttech.eu/api/documentation/#/Records/delete_api_user_v1_zones__zoneId__records__recordId_ -func (c Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error { +func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error { endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID, "records", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) @@ -144,7 +148,7 @@ func (c Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error return c.do(req, nil) } -func (c Client) do(req *http.Request, result any) error { +func (c *Client) do(req *http.Request, result any) error { resp, errD := c.httpClient.Do(req) if errD != nil { return errutils.NewHTTPDoError(req, errD) @@ -202,6 +206,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errAPI := &APIError{StatusCode: resp.StatusCode} + err := json.Unmarshal(raw, errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/hosttech/internal/client_test.go b/providers/dns/hosttech/internal/client_test.go index bf90acc9f..223a0d9cf 100644 --- a/providers/dns/hosttech/internal/client_test.go +++ b/providers/dns/hosttech/internal/client_test.go @@ -1,26 +1,40 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const testAPIKey = "secret" -func TestClient_GetZones(t *testing.T) { - client := setupTest(t, "/user/v1/zones", testHandler(http.MethodGet, http.StatusOK, "zones.json")) +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(OAuthStaticAccessToken(server.Client(), testAPIKey)) + client.baseURL, _ = url.Parse(server.URL) - zones, err := client.GetZones(context.Background(), "", 100, 0) + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer secret")) +} + +func TestClient_GetZones(t *testing.T) { + client := mockBuilder(). + Route("GET /user/v1/zones", + servermock.ResponseFromFixture("zones.json"), + servermock.CheckQueryParameter().Strict(). + With("limit", "100"). + With("query", "")). + Build(t) + + zones, err := client.GetZones(t.Context(), "", 100, 0) require.NoError(t, err) expected := []Zone{ @@ -39,16 +53,23 @@ func TestClient_GetZones(t *testing.T) { } func TestClient_GetZones_error(t *testing.T) { - client := setupTest(t, "/user/v1/zones", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("GET /user/v1/zones", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - _, err := client.GetZones(context.Background(), "", 100, 0) + _, err := client.GetZones(t.Context(), "", 100, 0) require.Error(t, err) } func TestClient_GetZone(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123", testHandler(http.MethodGet, http.StatusOK, "zone.json")) + client := mockBuilder(). + Route("GET /user/v1/zones/123", + servermock.ResponseFromFixture("zone.json")). + Build(t) - zone, err := client.GetZone(context.Background(), "123") + zone, err := client.GetZone(t.Context(), "123") require.NoError(t, err) expected := &Zone{ @@ -65,16 +86,25 @@ func TestClient_GetZone(t *testing.T) { } func TestClient_GetZone_error(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("GET /user/v1/zones/123", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - _, err := client.GetZone(context.Background(), "123") - require.Error(t, err) + _, err := client.GetZone(t.Context(), "123") + require.EqualError(t, err, "401: Unauthenticated.") } func TestClient_GetRecords(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123/records", testHandler(http.MethodGet, http.StatusOK, "records.json")) + client := mockBuilder(). + Route("GET /user/v1/zones/123/records", + servermock.ResponseFromFixture("records.json"), + servermock.CheckQueryParameter().Strict(). + With("type", "TXT")). + Build(t) - records, err := client.GetRecords(context.Background(), "123", "TXT") + records, err := client.GetRecords(t.Context(), "123", "TXT") require.NoError(t, err) expected := []Record{ @@ -152,14 +182,22 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecords_error(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123/records", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("GET /user/v1/zones/123/records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - _, err := client.GetRecords(context.Background(), "123", "TXT") - require.Error(t, err) + _, err := client.GetRecords(t.Context(), "123", "TXT") + require.EqualError(t, err, "401: Unauthenticated.") } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123/records", testHandler(http.MethodPost, http.StatusCreated, "record.json")) + client := mockBuilder(). + Route("POST /user/v1/zones/123/records", + servermock.ResponseFromFixture("record.json"). + WithStatusCode(http.StatusCreated)). + Build(t) record := Record{ Type: "TXT", @@ -169,7 +207,7 @@ func TestClient_AddRecord(t *testing.T) { Comment: "example", } - newRecord, err := client.AddRecord(context.Background(), "123", record) + newRecord, err := client.AddRecord(t.Context(), "123", record) require.NoError(t, err) expected := &Record{ @@ -185,7 +223,11 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123/records", testHandler(http.MethodPost, http.StatusUnauthorized, "error-details.json")) + client := mockBuilder(). + Route("POST /user/v1/zones/123/records", + servermock.ResponseFromFixture("error-details.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) record := Record{ Type: "TXT", @@ -195,69 +237,28 @@ func TestClient_AddRecord_error(t *testing.T) { Comment: "example", } - _, err := client.AddRecord(context.Background(), "123", record) - require.Error(t, err) + _, err := client.AddRecord(t.Context(), "123", record) + require.EqualError(t, err, "401: The given data was invalid. type: [Darf nicht leer sein.]") } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123/records/6", testHandler(http.MethodDelete, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("DELETE /user/v1/zones/123/records/6", + servermock.Noop().WithStatusCode(http.StatusNoContent). + WithStatusCode(http.StatusCreated)). + Build(t) - err := client.DeleteRecord(context.Background(), "123", "6") + err := client.DeleteRecord(t.Context(), "123", "6") require.Error(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123/records/6", testHandler(http.MethodDelete, http.StatusNoContent, "")) + client := mockBuilder(). + Route("DELETE /user/v1/zones/123/records/6", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - err := client.DeleteRecord(context.Background(), "123", "6") - require.NoError(t, err) -} - -func setupTest(t *testing.T, path string, handler http.Handler) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.Handle(path, handler) - - client := NewClient(OAuthStaticAccessToken(server.Client(), testAPIKey)) - client.baseURL, _ = url.Parse(server.URL) - - return client -} - -func testHandler(method string, statusCode int, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) - return - } - - if req.Header.Get("Authorization") != "Bearer "+testAPIKey { - http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized) - return - } - - rw.WriteHeader(statusCode) - - if statusCode == http.StatusNoContent { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - } + err := client.DeleteRecord(t.Context(), "123", "6") + require.EqualError(t, err, "401: Unauthenticated.") } diff --git a/providers/dns/hosttech/internal/types.go b/providers/dns/hosttech/internal/types.go index bf86964f7..a4b5b564d 100644 --- a/providers/dns/hosttech/internal/types.go +++ b/providers/dns/hosttech/internal/types.go @@ -2,6 +2,7 @@ package internal import ( "fmt" + "strings" ) type apiResponse[T any] struct { @@ -15,11 +16,15 @@ type APIError struct { } func (a APIError) Error() string { - msg := fmt.Sprintf("%d: %s", a.StatusCode, a.Message) + msg := new(strings.Builder) + + _, _ = fmt.Fprintf(msg, "%d: %s", a.StatusCode, a.Message) + for k, v := range a.Errors { - msg += fmt.Sprintf(" %s: %v", k, v) + _, _ = fmt.Fprintf(msg, " %s: %v", k, v) } - return msg + + return msg.String() } type Zone struct { diff --git a/providers/dns/httpnet/httpnet.go b/providers/dns/httpnet/httpnet.go index 69f22e4bd..4a88f1092 100644 --- a/providers/dns/httpnet/httpnet.go +++ b/providers/dns/httpnet/httpnet.go @@ -2,14 +2,12 @@ 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/hostingde" @@ -28,15 +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 { @@ -44,7 +39,7 @@ func NewDefaultConfig() *Config { ZoneName: env.GetOrFile(EnvZoneName), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, @@ -53,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. @@ -81,143 +72,36 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("httpnet: the configuration of the DNS provider is nil") } - if config.APIKey == "" { - return nil, errors.New("httpnet: API key missing") + provider, err := hostingde.NewDNSProviderConfig(config, defaultBaseURL) + if err != nil { + return nil, fmt.Errorf("httpnet: %w", err) } - client := hostingde.NewClient(config.APIKey) - client.BaseURL, _ = url.Parse(hostingde.DefaultHTTPNetBaseURL) - - return &DNSProvider{ - config: config, - client: client, - recordIDs: make(map[string]string), - }, nil + return &DNSProvider{prv: provider}, nil } -// Timeout returns the timeout and interval to use when checking for DNS propagation. -// Adjusting here to cope with spikes in propagation times. -func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.config.PropagationTimeout, d.config.PollingInterval -} - -// Present creates a TXT record to fulfill the dns-01 challenge. +// Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - zoneName, err := d.getZoneName(info.EffectiveFQDN) - if err != nil { - return fmt.Errorf("httpnet: could not find zone for domain %q: %w", domain, err) - } - - ctx := context.Background() - - // get the ZoneConfig for that domain - zonesFind := hostingde.ZoneConfigsFindRequest{ - Filter: hostingde.Filter{Field: "zoneName", Value: zoneName}, - Limit: 1, - Page: 1, - } - - zoneConfig, err := d.client.GetZone(ctx, zonesFind) + err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("httpnet: %w", err) } - zoneConfig.Name = zoneName - - rec := []hostingde.DNSRecord{{ - Type: "TXT", - Name: dns01.UnFqdn(info.EffectiveFQDN), - Content: info.Value, - TTL: d.config.TTL, - }} - - req := hostingde.ZoneUpdateRequest{ - ZoneConfig: *zoneConfig, - RecordsToAdd: rec, - } - - response, err := d.client.UpdateZone(ctx, req) - if err != nil { - return fmt.Errorf("httpnet: %w", err) - } - - for _, record := range response.Records { - if record.Name == dns01.UnFqdn(info.EffectiveFQDN) && record.Content == fmt.Sprintf(`%q`, info.Value) { - d.recordIDsMu.Lock() - d.recordIDs[info.EffectiveFQDN] = record.ID - d.recordIDsMu.Unlock() - } - } - - if d.recordIDs[info.EffectiveFQDN] == "" { - return fmt.Errorf("httpnet: error getting ID of just created record, for domain %s", domain) - } - return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - zoneName, err := d.getZoneName(info.EffectiveFQDN) - if err != nil { - return fmt.Errorf("httpnet: could not find zone for domain %q: %w", domain, err) - } - - ctx := context.Background() - - // get the ZoneConfig for that domain - zonesFind := hostingde.ZoneConfigsFindRequest{ - Filter: hostingde.Filter{Field: "zoneName", Value: zoneName}, - Limit: 1, - Page: 1, - } - - zoneConfig, err := d.client.GetZone(ctx, zonesFind) + err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("httpnet: %w", err) } - zoneConfig.Name = zoneName - rec := []hostingde.DNSRecord{{ - Type: "TXT", - Name: dns01.UnFqdn(info.EffectiveFQDN), - Content: `"` + info.Value + `"`, - }} - - req := hostingde.ZoneUpdateRequest{ - ZoneConfig: *zoneConfig, - RecordsToDelete: rec, - } - - // Delete record ID from map - d.recordIDsMu.Lock() - delete(d.recordIDs, info.EffectiveFQDN) - d.recordIDsMu.Unlock() - - _, err = d.client.UpdateZone(ctx, req) - if err != nil { - return fmt.Errorf("httpnet: %w", err) - } return nil } -func (d *DNSProvider) getZoneName(fqdn string) (string, error) { - if d.config.ZoneName != "" { - return d.config.ZoneName, nil - } - - zoneName, err := dns01.FindZoneByFqdn(fqdn) - if err != nil { - return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err) - } - - if zoneName == "" { - return "", errors.New("empty zone name") - } - - return dns01.UnFqdn(zoneName), nil +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.prv.Timeout() } diff --git a/providers/dns/httpnet/httpnet.toml b/providers/dns/httpnet/httpnet.toml index baf170973..3dd581204 100644 --- a/providers/dns/httpnet/httpnet.toml +++ b/providers/dns/httpnet/httpnet.toml @@ -6,7 +6,7 @@ Since = "v4.15.0" Example = ''' HTTPNET_API_KEY=xxxxxxxx \ -lego --email you@example.com --dns httpnet -d '*.example.com' -d example.com run +lego --dns httpnet -d '*.example.com' -d example.com run ''' [Configuration] @@ -14,10 +14,10 @@ lego --email you@example.com --dns httpnet -d '*.example.com' -d example.com run HTTPNET_API_KEY = "API key" [Configuration.Additional] HTTPNET_ZONE_NAME = "Zone name in ACE format" - HTTPNET_POLLING_INTERVAL = "Time between DNS propagation check" - HTTPNET_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - HTTPNET_TTL = "The TTL of the TXT record used for the DNS challenge" - HTTPNET_HTTP_TIMEOUT = "API request timeout" + HTTPNET_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + HTTPNET_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + HTTPNET_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + HTTPNET_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.http.net/docs/api/#dns" diff --git a/providers/dns/httpnet/httpnet_test.go b/providers/dns/httpnet/httpnet_test.go index a9bc527ad..ef1d2a1b7 100644 --- a/providers/dns/httpnet/httpnet_test.go +++ b/providers/dns/httpnet/httpnet_test.go @@ -49,6 +49,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -58,8 +59,7 @@ func TestNewDNSProvider(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - require.NotNil(t, p.config) - require.NotNil(t, p.recordIDs) + require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -101,8 +101,7 @@ func TestNewDNSProviderConfig(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - require.NotNil(t, p.config) - require.NotNil(t, p.recordIDs) + require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -116,6 +115,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -129,6 +129,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/httpreq/httpreq.go b/providers/dns/httpreq/httpreq.go index 81b3a6982..591e9b5e1 100644 --- a/providers/dns/httpreq/httpreq.go +++ b/providers/dns/httpreq/httpreq.go @@ -11,8 +11,10 @@ import ( "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/errutils" ) @@ -30,6 +32,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + type message struct { FQDN string `json:"fqdn"` Value string `json:"value"` @@ -85,6 +89,7 @@ func NewDNSProvider() (*DNSProvider, error) { config.Username = env.GetOrFile(EnvUsername) config.Password = env.GetOrFile(EnvPassword) config.Endpoint = endpoint + return NewDNSProviderConfig(config) } @@ -98,6 +103,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("httpreq: the endpoint is missing") } + config.HTTPClient = clientdebug.Wrap(config.HTTPClient) + return &DNSProvider{config: config}, nil } @@ -122,6 +129,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("httpreq: %w", err) } + return nil } @@ -135,6 +143,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("httpreq: %w", err) } + return nil } @@ -153,6 +162,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("httpreq: %w", err) } + return nil } @@ -166,11 +176,13 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("httpreq: %w", err) } + return nil } func (d *DNSProvider) doPost(ctx context.Context, uri string, msg any) error { reqBody := new(bytes.Buffer) + err := json.NewEncoder(reqBody).Encode(msg) if err != nil { return fmt.Errorf("failed to create request JSON body: %w", err) diff --git a/providers/dns/httpreq/httpreq.toml b/providers/dns/httpreq/httpreq.toml index 43f3e4f62..d64d61a6c 100644 --- a/providers/dns/httpreq/httpreq.toml +++ b/providers/dns/httpreq/httpreq.toml @@ -6,7 +6,7 @@ Since = "v2.0.0" Example = ''' HTTPREQ_ENDPOINT=http://my.server.com:9090 \ -lego --email you@example.com --dns httpreq -d '*.example.com' -d example.com run +lego --dns httpreq -d '*.example.com' -d example.com run ''' Additional = ''' @@ -56,6 +56,6 @@ Basic authentication (optional) can be set with some environment variables: [Configuration.Additional] HTTPREQ_USERNAME = "Basic authentication username" HTTPREQ_PASSWORD = "Basic authentication password" - HTTPREQ_POLLING_INTERVAL = "Time between DNS propagation check" - HTTPREQ_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - HTTPREQ_HTTP_TIMEOUT = "API request timeout" + HTTPREQ_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + HTTPREQ_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + HTTPREQ_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" diff --git a/providers/dns/httpreq/httpreq_test.go b/providers/dns/httpreq/httpreq_test.go index 8dc36ccc6..108d6a565 100644 --- a/providers/dns/httpreq/httpreq_test.go +++ b/providers/dns/httpreq/httpreq_test.go @@ -1,15 +1,12 @@ package httpreq import ( - "encoding/json" - "fmt" - "net/http" "net/http/httptest" "net/url" - "path" "testing" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) @@ -46,6 +43,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -102,75 +100,60 @@ func TestNewDNSProvider_Present(t *testing.T) { testCases := []struct { desc string - mode string - username string - password string - pathPrefix string - handler http.HandlerFunc + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "success", - handler: successHandler, + desc: "success", + builder: mockBuilder(""). + Route("/present", + servermock.RawStringResponse("lego"), + servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)), }, { - desc: "success with path prefix", - handler: successHandler, - pathPrefix: "/api/acme/", + desc: "success with path prefix", + builder: mockBuilderWithPathPrefix("", "/api/acme/"). + Route("/api/acme/present", + servermock.RawStringResponse("lego"), + servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)), }, { desc: "error", - handler: http.NotFound, + builder: mockBuilder(""), expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found", }, { - desc: "success raw mode", - mode: "RAW", - handler: successRawModeHandler, + desc: "success raw mode", + builder: mockBuilder("RAW"). + Route("/present", + servermock.RawStringResponse("lego"), + servermock.CheckRequestBody(`{"domain":"domain","token":"token","keyAuth":"key"}`)), }, { desc: "error raw mode", - mode: "RAW", - handler: http.NotFound, + builder: mockBuilder("RAW"), expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found", }, { - desc: "basic auth", - username: "bar", - password: "foo", - handler: func(rw http.ResponseWriter, req *http.Request) { - username, password, ok := req.BasicAuth() - if username != "bar" || password != "foo" || !ok { - rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and password.")) - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - fmt.Fprint(rw, "lego") - }, + desc: "basic auth fail", + builder: mockBuilderWithBasicAuth("nope", "nope"). + Route("/present", servermock.Noop()), + expectedError: `httpreq: unexpected status code: [status code: 400] body: invalid credentials: got [username: "nope", password: "nope"], want [username: "user", password: "secret"]`, + }, + { + desc: "basic auth success", + builder: mockBuilderWithBasicAuth("user", "secret"). + Route("/present", + servermock.RawStringResponse("lego"), + servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - t.Parallel() + p := test.builder.Build(t) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(path.Join("/", test.pathPrefix, "present"), test.handler) - - config := NewDefaultConfig() - config.Endpoint = mustParse(server.URL + test.pathPrefix) - config.Mode = test.mode - config.Username = test.username - config.Password = test.password - - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - err = p.Present("domain", "token", "key") + err := p.Present("domain", "token", "key") if test.expectedError == "" { require.NoError(t, err) } else { @@ -185,68 +168,53 @@ func TestNewDNSProvider_Cleanup(t *testing.T) { testCases := []struct { desc string - mode string - username string - password string - handler http.HandlerFunc + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "success", - handler: successHandler, + desc: "success", + builder: mockBuilder(""). + Route("/cleanup", + servermock.RawStringResponse("lego"), + servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)), }, { desc: "error", - handler: http.NotFound, + builder: mockBuilder(""), expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found", }, { - desc: "success raw mode", - mode: "RAW", - handler: successRawModeHandler, + desc: "success raw mode", + builder: mockBuilder("RAW"). + Route("/cleanup", + servermock.RawStringResponse("lego"), + servermock.CheckRequestBody(`{"domain":"domain","token":"token","keyAuth":"key"}`)), }, { desc: "error raw mode", - mode: "RAW", - handler: http.NotFound, + builder: mockBuilder("RAW"), expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found", }, { - desc: "basic auth", - username: "bar", - password: "foo", - handler: func(rw http.ResponseWriter, req *http.Request) { - username, password, ok := req.BasicAuth() - if username != "bar" || password != "foo" || !ok { - rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and password.")) - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - fmt.Fprint(rw, "lego") - }, + desc: "basic auth fail", + builder: mockBuilderWithBasicAuth("test", "example"). + Route("/cleanup", servermock.Noop()), + expectedError: `httpreq: unexpected status code: [status code: 400] body: invalid credentials: got [username: "test", password: "example"], want [username: "user", password: "secret"]`, + }, + { + desc: "basic auth success", + builder: mockBuilderWithBasicAuth("user", "secret"). + Route("/cleanup", + servermock.RawStringResponse("lego"), + servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - t.Parallel() + p := test.builder.Build(t) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/cleanup", test.handler) - - config := NewDefaultConfig() - config.Endpoint = mustParse(server.URL) - config.Mode = test.mode - config.Username = test.username - config.Password = test.password - - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - err = p.CleanUp("domain", "token", "key") + err := p.CleanUp("domain", "token", "key") if test.expectedError == "" { require.NoError(t, err) } else { @@ -256,36 +224,42 @@ func TestNewDNSProvider_Cleanup(t *testing.T) { } } -func successHandler(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } +func mockBuilder(mode string) *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.HTTPClient = server.Client() + config.Endpoint, _ = url.Parse(server.URL) + config.Mode = mode - msg := &message{} - err := json.NewDecoder(req.Body).Decode(msg) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - fmt.Fprint(rw, "lego") + return NewDNSProviderConfig(config) + }) } -func successRawModeHandler(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } +func mockBuilderWithPathPrefix(mode, prefix string) *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.HTTPClient = server.Client() + config.Endpoint, _ = url.Parse(server.URL + prefix) + config.Mode = mode - msg := &messageRaw{} - err := json.NewDecoder(req.Body).Decode(msg) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } + return NewDNSProviderConfig(config) + }) +} - fmt.Fprint(rw, "lego") +func mockBuilderWithBasicAuth(username, password string) *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.HTTPClient = server.Client() + config.Endpoint, _ = url.Parse(server.URL) + config.Username = username + config.Password = password + + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader().WithBasicAuth("user", "secret")) } func mustParse(rawURL string) *url.URL { @@ -293,5 +267,6 @@ func mustParse(rawURL string) *url.URL { if err != nil { panic(err) } + return uri } diff --git a/providers/dns/huaweicloud/huaweicloud.go b/providers/dns/huaweicloud/huaweicloud.go index 7f32f76d6..e47f3e2b5 100644 --- a/providers/dns/huaweicloud/huaweicloud.go +++ b/providers/dns/huaweicloud/huaweicloud.go @@ -2,6 +2,7 @@ package huaweicloud import ( + "context" "errors" "fmt" "strconv" @@ -9,9 +10,13 @@ import ( "sync" "time" + "github.com/cenkalti/backoff/v5" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/wait" + "github.com/go-acme/lego/v4/providers/dns/huaweicloud/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" hwauthbasic "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/basic" hwconfig "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/config" hwdns "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2" @@ -33,6 +38,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { AccessKeyID string @@ -58,7 +65,7 @@ func NewDefaultConfig() *Config { // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config - client *hwdns.DnsClient + client *internal.DnsClient recordIDs map[string]string recordIDsMu sync.Mutex @@ -115,7 +122,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return &DNSProvider{ config: config, - client: hwdns.NewDnsClient(client), + client: internal.NewDnsClient(client), recordIDs: map[string]string{}, }, nil } @@ -143,19 +150,27 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { d.recordIDs[token] = recordSetID d.recordIDsMu.Unlock() - err = wait.For("record set sync on "+domain, d.config.PropagationTimeout, d.config.PollingInterval, func() (bool, error) { - rs, errShow := d.client.ShowRecordSet(&hwmodel.ShowRecordSetRequest{ - ZoneId: zoneID, - RecordsetId: recordSetID, - }) - if errShow != nil { - return false, fmt.Errorf("show record set: %w", errShow) - } + err = wait.Retry(context.Background(), + func() error { + rs, errShow := d.client.ShowRecordSet(&hwmodel.ShowRecordSetRequest{ + ZoneId: zoneID, + RecordsetId: recordSetID, + }) + if errShow != nil { + return fmt.Errorf("show record set: %w", errShow) + } - return !strings.HasSuffix(deref(rs.Status), "PENDING_"), nil - }) + if !strings.HasSuffix(ptr.Deref(rs.Status), "PENDING_") { + return nil + } + + return fmt.Errorf("status: %s", ptr.Deref(rs.Status)) + }, + backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)), + backoff.WithMaxElapsedTime(d.config.PropagationTimeout), + ) if err != nil { - return fmt.Errorf("huaweicloud: %w", err) + return fmt.Errorf("huaweicloud: record set sync on %s: %w", domain, err) } return nil @@ -169,6 +184,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("huaweicloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } @@ -193,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 } @@ -205,7 +225,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) getOrCreateRecordSetID(domain, zoneID string, info dns01.ChallengeInfo) (string, error) { records, err := d.client.ListRecordSetsByZone(&hwmodel.ListRecordSetsByZoneRequest{ ZoneId: zoneID, - Name: pointer(info.EffectiveFQDN), + Name: ptr.Pointer(info.EffectiveFQDN), }) if err != nil { return "", fmt.Errorf("record list: unable to get record %s for zone %s: %w", info.EffectiveFQDN, domain, err) @@ -213,8 +233,8 @@ func (d *DNSProvider) getOrCreateRecordSetID(domain, zoneID string, info dns01.C var existingRecordSet *hwmodel.ListRecordSets - for _, record := range deref(records.Recordsets) { - if deref(record.Type) == "TXT" && deref(record.Name) == info.EffectiveFQDN { + for _, record := range ptr.Deref(records.Recordsets) { + if ptr.Deref(record.Type) == "TXT" && ptr.Deref(record.Name) == info.EffectiveFQDN { existingRecordSet = &record } } @@ -226,9 +246,9 @@ func (d *DNSProvider) getOrCreateRecordSetID(domain, zoneID string, info dns01.C ZoneId: zoneID, Body: &hwmodel.CreateRecordSetRequestBody{ Name: info.EffectiveFQDN, - Description: pointer("Added TXT record for ACME dns-01 challenge using lego client"), + Description: ptr.Pointer("Added TXT record for ACME dns-01 challenge using lego client"), Type: "TXT", - Ttl: pointer(d.config.TTL), + Ttl: ptr.Pointer(d.config.TTL), Records: []string{value}, }, } @@ -238,18 +258,18 @@ func (d *DNSProvider) getOrCreateRecordSetID(domain, zoneID string, info dns01.C return "", fmt.Errorf("create record set: %w", errCreate) } - return deref(resp.Id), nil + return ptr.Deref(resp.Id), nil } updateRequest := &hwmodel.UpdateRecordSetRequest{ ZoneId: zoneID, - RecordsetId: deref(existingRecordSet.Id), + RecordsetId: ptr.Deref(existingRecordSet.Id), Body: &hwmodel.UpdateRecordSetReq{ Name: existingRecordSet.Name, Description: existingRecordSet.Description, Type: existingRecordSet.Type, Ttl: existingRecordSet.Ttl, - Records: pointer(append(deref(existingRecordSet.Records), value)), + Records: ptr.Pointer(append(ptr.Deref(existingRecordSet.Records), value)), }, } @@ -258,7 +278,7 @@ func (d *DNSProvider) getOrCreateRecordSetID(domain, zoneID string, info dns01.C return "", fmt.Errorf("update record set: %w", err) } - return deref(resp.Id), nil + return ptr.Deref(resp.Id), nil } func (d *DNSProvider) getZoneID(authZone string) (string, error) { @@ -267,22 +287,11 @@ func (d *DNSProvider) getZoneID(authZone string) (string, error) { return "", fmt.Errorf("unable to get zone: %w", err) } - for _, zone := range deref(zones.Zones) { - if deref(zone.Name) == authZone { - return deref(zone.Id), nil + for _, zone := range ptr.Deref(zones.Zones) { + if ptr.Deref(zone.Name) == authZone { + return ptr.Deref(zone.Id), nil } } return "", fmt.Errorf("zone %q not found", authZone) } - -func pointer[T any](v T) *T { return &v } - -func deref[T any](v *T) T { - if v == nil { - var zero T - return zero - } - - return *v -} diff --git a/providers/dns/huaweicloud/huaweicloud.toml b/providers/dns/huaweicloud/huaweicloud.toml index 423dd9d7d..e8d417c11 100644 --- a/providers/dns/huaweicloud/huaweicloud.toml +++ b/providers/dns/huaweicloud/huaweicloud.toml @@ -8,7 +8,7 @@ Example = ''' HUAWEICLOUD_ACCESS_KEY_ID=your-access-key-id \ HUAWEICLOUD_SECRET_ACCESS_KEY=your-secret-access-key \ HUAWEICLOUD_REGION=cn-south-1 \ -lego --email you@example.com --dns huaweicloud -d '*.example.com' -d example.com run +lego --dns huaweicloud -d '*.example.com' -d example.com run ''' [Configuration] @@ -18,10 +18,10 @@ lego --email you@example.com --dns huaweicloud -d '*.example.com' -d example.com HUAWEICLOUD_REGION = "Region" [Configuration.Additional] - HUAWEICLOUD_POLLING_INTERVAL = "Time between DNS propagation check" - HUAWEICLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - HUAWEICLOUD_TTL = "The TTL of the TXT record used for the DNS challenge" - HUAWEICLOUD_HTTP_TIMEOUT = "API request timeout" + HUAWEICLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + HUAWEICLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + HUAWEICLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + HUAWEICLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://console-intl.huaweicloud.com/apiexplorer/#/openapi/DNS/doc?locale=en-us" diff --git a/providers/dns/huaweicloud/huaweicloud_test.go b/providers/dns/huaweicloud/huaweicloud_test.go index 02ba1576d..25e295da7 100644 --- a/providers/dns/huaweicloud/huaweicloud_test.go +++ b/providers/dns/huaweicloud/huaweicloud_test.go @@ -20,18 +20,7 @@ func TestNewDNSProvider(t *testing.T) { envVars map[string]string expected string }{ - { - desc: "success", - envVars: map[string]string{ - EnvAccessKeyID: "123", - EnvSecretAccessKey: "456", - EnvRegion: hwregion.CN_EAST_2.Id, - }, - // The "success" cannot be tested because there is an API call that require a valid authentication. - // Also, there is a bug during the error message creation: - // https://github.com/huaweicloud/huaweicloud-sdk-go-v3/pull/81 - expected: "huaweicloud: client build: runtime error: invalid memory address or nil pointer dereference", - }, + // The "success" cannot be tested because there is an API call that require a valid authentication. { desc: "missing credentials", envVars: map[string]string{ @@ -73,6 +62,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -99,16 +89,7 @@ func TestNewDNSProviderConfig(t *testing.T) { region string expected string }{ - { - desc: "success", - accessKeyID: "123", - secretAccessKey: "456", - region: hwregion.CN_EAST_2.Id, - // The "success" cannot be tested because there is an API call that require a valid authentication. - // Also, there is a bug during the error message creation: - // https://github.com/huaweicloud/huaweicloud-sdk-go-v3/pull/81 - expected: "huaweicloud: client build: runtime error: invalid memory address or nil pointer dereference", - }, + // The "success" cannot be tested because there is an API call that require a valid authentication. { desc: "missing credentials", expected: "huaweicloud: credentials missing", @@ -160,6 +141,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -173,6 +155,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/huaweicloud/internal/client.go b/providers/dns/huaweicloud/internal/client.go new file mode 100644 index 000000000..f10cf2dff --- /dev/null +++ b/providers/dns/huaweicloud/internal/client.go @@ -0,0 +1,92 @@ +/* +Copyright (c) Huawei Technologies Co., Ltd. 2020-present. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package internal is a partial copy of https://github.com/huaweicloud/huaweicloud-sdk-go-v3/blob/v0.1.159/services/dns/v2/dns_client.go +package internal + +import ( + httpclient "github.com/huaweicloud/huaweicloud-sdk-go-v3/core" + hwdns "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2" + "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2/model" +) + +type DnsClient struct { + HcClient *httpclient.HcHttpClient +} + +func NewDnsClient(hcClient *httpclient.HcHttpClient) *DnsClient { + return &DnsClient{HcClient: hcClient} +} + +func (c *DnsClient) ShowRecordSet(request *model.ShowRecordSetRequest) (*model.ShowRecordSetResponse, error) { + requestDef := hwdns.GenReqDefForShowRecordSet() + + if resp, err := c.HcClient.Sync(request, requestDef); err != nil { + return nil, err + } else { + return resp.(*model.ShowRecordSetResponse), nil + } +} + +func (c *DnsClient) CreateRecordSet(request *model.CreateRecordSetRequest) (*model.CreateRecordSetResponse, error) { + requestDef := hwdns.GenReqDefForCreateRecordSet() + + if resp, err := c.HcClient.Sync(request, requestDef); err != nil { + return nil, err + } else { + return resp.(*model.CreateRecordSetResponse), nil + } +} + +func (c *DnsClient) UpdateRecordSet(request *model.UpdateRecordSetRequest) (*model.UpdateRecordSetResponse, error) { + requestDef := hwdns.GenReqDefForUpdateRecordSet() + + if resp, err := c.HcClient.Sync(request, requestDef); err != nil { + return nil, err + } else { + return resp.(*model.UpdateRecordSetResponse), nil + } +} + +func (c *DnsClient) DeleteRecordSet(request *model.DeleteRecordSetRequest) (*model.DeleteRecordSetResponse, error) { + requestDef := hwdns.GenReqDefForDeleteRecordSet() + + if resp, err := c.HcClient.Sync(request, requestDef); err != nil { + return nil, err + } else { + return resp.(*model.DeleteRecordSetResponse), nil + } +} + +func (c *DnsClient) ListRecordSetsByZone(request *model.ListRecordSetsByZoneRequest) (*model.ListRecordSetsByZoneResponse, error) { + requestDef := hwdns.GenReqDefForListRecordSetsByZone() + + if resp, err := c.HcClient.Sync(request, requestDef); err != nil { + return nil, err + } else { + return resp.(*model.ListRecordSetsByZoneResponse), nil + } +} + +func (c *DnsClient) ListPublicZones(request *model.ListPublicZonesRequest) (*model.ListPublicZonesResponse, error) { + requestDef := hwdns.GenReqDefForListPublicZones() + + if resp, err := c.HcClient.Sync(request, requestDef); err != nil { + return nil, err + } else { + return resp.(*model.ListPublicZonesResponse), nil + } +} diff --git a/providers/dns/hurricane/hurricane.go b/providers/dns/hurricane/hurricane.go index d17ceb892..b23528bb0 100644 --- a/providers/dns/hurricane/hurricane.go +++ b/providers/dns/hurricane/hurricane.go @@ -5,12 +5,13 @@ import ( "errors" "fmt" "net/http" - "strings" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/hurricane/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -25,6 +26,8 @@ const ( EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Credentials map[string]string @@ -55,14 +58,15 @@ type DNSProvider struct { // NewDNSProvider returns a DNSProvider instance configured for Hurricane Electric. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() + values, err := env.Get(EnvTokens) if err != nil { return nil, fmt.Errorf("hurricane: %w", err) } - credentials, err := parseCredentials(values[EnvTokens]) + credentials, err := env.ParsePairs(values[EnvTokens]) if err != nil { - return nil, fmt.Errorf("hurricane: %w", err) + return nil, fmt.Errorf("hurricane: credentials: %w", err) } config.Credentials = credentials @@ -81,6 +85,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client := internal.NewClient(config.Credentials) + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } @@ -119,19 +129,3 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } - -func parseCredentials(raw string) (map[string]string, error) { - credentials := make(map[string]string) - - credStrings := strings.Split(strings.TrimSuffix(raw, ","), ",") - for _, credPair := range credStrings { - data := strings.Split(credPair, ":") - if len(data) != 2 { - return nil, fmt.Errorf("incorrect credential pair: %s", credPair) - } - - credentials[strings.TrimSpace(data[0])] = strings.TrimSpace(data[1]) - } - - return credentials, nil -} diff --git a/providers/dns/hurricane/hurricane.toml b/providers/dns/hurricane/hurricane.toml index 88e73dea9..10b370e4f 100644 --- a/providers/dns/hurricane/hurricane.toml +++ b/providers/dns/hurricane/hurricane.toml @@ -6,10 +6,10 @@ Since = "v4.3.0" Example = ''' HURRICANE_TOKENS=example.org:token \ -lego --email you@example.com --dns hurricane -d '*.example.com' -d example.com run +lego --dns hurricane -d '*.example.com' -d example.com run HURRICANE_TOKENS=my.example.org:token1,demo.example.org:token2 \ -lego --email you@example.com --dns hurricane -d my.example.org -d demo.example.org +lego --dns hurricane -d my.example.org -d demo.example.org ''' Additional = """ @@ -39,10 +39,10 @@ HURRICANE_TOKENS=example.org:token [Configuration.Credentials] HURRICANE_TOKENS = "TXT record names and tokens" [Configuration.Additional] - HURRICANE_POLLING_INTERVAL = "Time between DNS propagation checks" - HURRICANE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation; defaults to 300s (5 minutes)" - HURRICANE_SEQUENCE_INTERVAL = "Time between sequential requests" - HURRICANE_HTTP_TIMEOUT = "API request timeout" + HURRICANE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + HURRICANE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation (Default: 300)" + HURRICANE_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" + HURRICANE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://dns.he.net/" diff --git a/providers/dns/hurricane/hurricane_test.go b/providers/dns/hurricane/hurricane_test.go index 12217c790..2bbd638fa 100644 --- a/providers/dns/hurricane/hurricane_test.go +++ b/providers/dns/hurricane/hurricane_test.go @@ -34,14 +34,14 @@ func TestNewDNSProvider(t *testing.T) { envVars: map[string]string{ EnvTokens: ",", }, - expected: "hurricane: incorrect credential pair: ", + expected: "hurricane: credentials: incorrect pair: ", }, { desc: "invalid credentials, partial", envVars: map[string]string{ EnvTokens: "example.org:123,example.net", }, - expected: "hurricane: incorrect credential pair: example.net", + expected: "hurricane: credentials: incorrect pair: example.net", }, { desc: "missing credentials", @@ -55,6 +55,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -120,6 +121,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -133,6 +135,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/hurricane/internal/client.go b/providers/dns/hurricane/internal/client.go index 62ca76159..b758ec166 100644 --- a/providers/dns/hurricane/internal/client.go +++ b/providers/dns/hurricane/internal/client.go @@ -52,7 +52,7 @@ func NewClient(credentials map[string]string) *Client { } // UpdateTxtRecord updates a TXT record. -func (c *Client) UpdateTxtRecord(ctx context.Context, hostname string, txt string) error { +func (c *Client) UpdateTxtRecord(ctx context.Context, hostname, txt string) error { domain := strings.TrimPrefix(hostname, "_acme-challenge.") c.credMu.Lock() @@ -101,7 +101,7 @@ func (c *Client) UpdateTxtRecord(ctx context.Context, hostname string, txt strin return evaluateBody(string(bytes.TrimSpace(raw)), hostname) } -func evaluateBody(body string, hostname string) error { +func evaluateBody(body, hostname string) error { code, _, _ := strings.Cut(body, " ") switch code { diff --git a/providers/dns/hurricane/internal/client_test.go b/providers/dns/hurricane/internal/client_test.go index 2862c2481..d93f3e0ed 100644 --- a/providers/dns/hurricane/internal/client_test.go +++ b/providers/dns/hurricane/internal/client_test.go @@ -1,15 +1,21 @@ package internal import ( - "context" - "fmt" - "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" ) +func setupClient(server *httptest.Server) (*Client, error) { + client := NewClient(map[string]string{"example.com": "secret"}) + client.baseURL = server.URL + client.HTTPClient = server.Client() + + return client, nil +} + func TestClient_UpdateTxtRecord(t *testing.T) { testCases := []struct { code string @@ -49,33 +55,16 @@ func TestClient_UpdateTxtRecord(t *testing.T) { t.Run(test.code, func(t *testing.T) { t.Parallel() - handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } + client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithContentTypeFromURLEncoded()). + Route("POST /", + servermock.RawStringResponse(test.code), + servermock.CheckForm().Strict(). + With("hostname", "_acme-challenge.example.com"). + With("password", "secret"). + With("txt", "foo")). + Build(t) - if err := req.ParseForm(); err != nil { - http.Error(rw, "failed to parse form data", http.StatusBadRequest) - return - } - - if req.PostForm.Encode() != "hostname=_acme-challenge.example.com&password=secret&txt=foo" { - http.Error(rw, "invalid form data", http.StatusBadRequest) - return - } - - _, _ = rw.Write([]byte(test.code)) - }) - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - client := NewClient(map[string]string{"example.com": "secret"}) - client.baseURL = server.URL - client.HTTPClient = server.Client() - - err := client.UpdateTxtRecord(context.Background(), "_acme-challenge.example.com", "foo") + err := client.UpdateTxtRecord(t.Context(), "_acme-challenge.example.com", "foo") test.expected(t, err) }) } diff --git a/providers/dns/hyperone/hyperone.go b/providers/dns/hyperone/hyperone.go index 8578b5c52..3cdad8e68 100644 --- a/providers/dns/hyperone/hyperone.go +++ b/providers/dns/hyperone/hyperone.go @@ -9,9 +9,11 @@ import ( "path/filepath" "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/hyperone/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. @@ -28,6 +30,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIEndpoint string @@ -73,6 +77,7 @@ func NewDNSProvider() (*DNSProvider, error) { func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config.PassportLocation == "" { var err error + config.PassportLocation, err = GetDefaultPassportLocation() if err != nil { return nil, fmt.Errorf("hyperone: %w", err) @@ -93,6 +98,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{client: client, config: config}, nil } @@ -160,6 +167,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { if err != nil { return fmt.Errorf("hyperone: %w", err) } + if len(records) == 1 { if records[0].Content != info.Value { return fmt.Errorf("hyperone: record with content %s not found: fqdn=%s", info.Value, info.EffectiveFQDN) diff --git a/providers/dns/hyperone/hyperone.toml b/providers/dns/hyperone/hyperone.toml index bebde3185..88814356f 100644 --- a/providers/dns/hyperone/hyperone.toml +++ b/providers/dns/hyperone/hyperone.toml @@ -5,7 +5,7 @@ Code = "hyperone" Since = "v3.9.0" Example = ''' -lego --email you@example.com --dns hyperone -d '*.example.com' -d example.com run +lego --dns hyperone -d '*.example.com' -d example.com run ''' Additional = ''' @@ -41,9 +41,10 @@ All required permissions are available via platform role `tool.lego`. HYPERONE_PASSPORT_LOCATION = "Allows to pass custom passport file location (default ~/.h1/passport.json)" HYPERONE_API_URL = "Allows to pass custom API Endpoint to be used in the challenge (default https://api.hyperone.com/v2)" HYPERONE_LOCATION_ID = "Specifies location (region) to be used in API calls. (default pl-waw-1)" - HYPERONE_TTL = "The TTL of the TXT record used for the DNS challenge" - HYPERONE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - HYPERONE_POLLING_INTERVAL = "Time between DNS propagation check" + HYPERONE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + HYPERONE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 2)" + HYPERONE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 60)" + HYPERONE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.hyperone.com/v2/docs" diff --git a/providers/dns/hyperone/hyperone_test.go b/providers/dns/hyperone/hyperone_test.go index 1222d1c74..675a1fe19 100644 --- a/providers/dns/hyperone/hyperone_test.go +++ b/providers/dns/hyperone/hyperone_test.go @@ -49,6 +49,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -124,6 +125,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -137,6 +139,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/hyperone/internal/client.go b/providers/dns/hyperone/internal/client.go index 09fa68768..cf9ab2a37 100644 --- a/providers/dns/hyperone/internal/client.go +++ b/providers/dns/hyperone/internal/client.go @@ -132,7 +132,7 @@ func (c *Client) CreateRecordset(ctx context.Context, zoneID, recordType, name, // DeleteRecordset deletes a recordset. // https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_delete -func (c *Client) DeleteRecordset(ctx context.Context, zoneID string, recordsetID string) error { +func (c *Client) DeleteRecordset(ctx context.Context, zoneID, recordsetID string) error { // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId} endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID) @@ -146,7 +146,7 @@ func (c *Client) DeleteRecordset(ctx context.Context, zoneID string, recordsetID // GetRecords gets all records within specified recordset. // https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_list -func (c *Client) GetRecords(ctx context.Context, zoneID string, recordsetID string) ([]Record, error) { +func (c *Client) GetRecords(ctx context.Context, zoneID, recordsetID string) ([]Record, error) { // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID, "record") diff --git a/providers/dns/hyperone/internal/client_test.go b/providers/dns/hyperone/internal/client_test.go index e3a1073e0..aa087c4f2 100644 --- a/providers/dns/hyperone/internal/client_test.go +++ b/providers/dns/hyperone/internal/client_test.go @@ -1,17 +1,10 @@ package internal import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -22,10 +15,34 @@ func (s signerMock) GetJWT() (string, error) { return "", nil } -func TestClient_FindRecordset(t *testing.T) { - client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone/zone321/recordset", respFromFile("recordset.json")) +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + passport := &Passport{ + SubjectID: "/iam/project/proj123/sa/xxxxxxx", + } - recordset, err := client.FindRecordset(context.Background(), "zone321", "SOA", "example.com.") + client, err := NewClient(server.URL, "loc123", passport) + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + client.signer = signerMock{} + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer")) +} + +func TestClient_FindRecordset(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/loc123/project/proj123/zone/zone321/recordset", + servermock.ResponseFromFixture("recordset.json")). + Build(t) + + recordset, err := client.FindRecordset(t.Context(), "zone321", "SOA", "example.com.") require.NoError(t, err) expected := &Recordset{ @@ -46,10 +63,13 @@ func TestClient_CreateRecordset(t *testing.T) { Record: &Record{Content: "value"}, } - client := setupTest(t, http.MethodPost, "/dns/loc123/project/proj123/zone/zone123/recordset", - hasReqBody(expectedReqBody), respFromFile("createRecordset.json")) + client := mockBuilder(). + Route("POST /dns/loc123/project/proj123/zone/zone123/recordset", + servermock.ResponseFromFixture("createRecordset.json"), + servermock.CheckRequestJSONBodyFromStruct(expectedReqBody)). + Build(t) - rs, err := client.CreateRecordset(context.Background(), "zone123", "TXT", "test.example.com.", "value", 3600) + rs, err := client.CreateRecordset(t.Context(), "zone123", "TXT", "test.example.com.", "value", 3600) require.NoError(t, err) expected := &Recordset{RecordType: "TXT", Name: "test.example.com.", TTL: 3600, ID: "1234567890qwertyuiop"} @@ -57,16 +77,21 @@ func TestClient_CreateRecordset(t *testing.T) { } func TestClient_DeleteRecordset(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns/loc123/project/proj123/zone/zone321/recordset/rs322") + client := mockBuilder(). + Route("DELETE /dns/loc123/project/proj123/zone/zone321/recordset/rs322", nil). + Build(t) - err := client.DeleteRecordset(context.Background(), "zone321", "rs322") + err := client.DeleteRecordset(t.Context(), "zone321", "rs322") require.NoError(t, err) } func TestClient_GetRecords(t *testing.T) { - client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone/321/recordset/322/record", respFromFile("record.json")) + client := mockBuilder(). + Route("GET /dns/loc123/project/proj123/zone/321/recordset/322/record", + servermock.ResponseFromFixture("record.json")). + Build(t) - records, err := client.GetRecords(context.Background(), "321", "322") + records, err := client.GetRecords(t.Context(), "321", "322") require.NoError(t, err) expected := []Record{ @@ -85,10 +110,13 @@ func TestClient_CreateRecord(t *testing.T) { Content: "value", } - client := setupTest(t, http.MethodPost, "/dns/loc123/project/proj123/zone/z123/recordset/rs325/record", - hasReqBody(expectedReqBody), respFromFile("createRecord.json")) + client := mockBuilder(). + Route("POST /dns/loc123/project/proj123/zone/z123/recordset/rs325/record", + servermock.ResponseFromFixture("createRecord.json"), + servermock.CheckRequestJSONBodyFromStruct(expectedReqBody)). + Build(t) - rs, err := client.CreateRecord(context.Background(), "z123", "rs325", "value") + rs, err := client.CreateRecord(t.Context(), "z123", "rs325", "value") require.NoError(t, err) expected := &Record{ID: "123321qwerqwewqerq", Content: "value", Enabled: true} @@ -96,16 +124,22 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns/loc123/project/proj123/zone/321/recordset/322/record/323") + client := mockBuilder(). + Route("DELETE /dns/loc123/project/proj123/zone/321/recordset/322/record/323", + servermock.ResponseFromFixture("createRecord.json")). + Build(t) - err := client.DeleteRecord(context.Background(), "321", "322", "323") + err := client.DeleteRecord(t.Context(), "321", "322", "323") require.NoError(t, err) } func TestClient_FindZone(t *testing.T) { - client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone", respFromFile("zones.json")) + client := mockBuilder(). + Route("GET /dns/loc123/project/proj123/zone", + servermock.ResponseFromFixture("zones.json")). + Build(t) - zone, err := client.FindZone(context.Background(), "example.com") + zone, err := client.FindZone(t.Context(), "example.com") require.NoError(t, err) expected := &Zone{ @@ -120,9 +154,12 @@ func TestClient_FindZone(t *testing.T) { } func TestClient_GetZones(t *testing.T) { - client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone", respFromFile("zones.json")) + client := mockBuilder(). + Route("GET /dns/loc123/project/proj123/zone", + servermock.ResponseFromFixture("zones.json")). + Build(t) - zones, err := client.GetZones(context.Background()) + zones, err := client.GetZones(t.Context()) require.NoError(t, err) expected := []Zone{ @@ -144,77 +181,3 @@ func TestClient_GetZones(t *testing.T) { assert.Equal(t, expected, zones) } - -func setupTest(t *testing.T, method, path string, handlers ...assertHandler) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.Handle(path, http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - if len(handlers) != 0 { - for _, handler := range handlers { - code, err := handler(rw, req) - if err != nil { - http.Error(rw, err.Error(), code) - return - } - } - } - })) - - passport := &Passport{ - SubjectID: "/iam/project/proj123/sa/xxxxxxx", - } - - client, err := NewClient(server.URL, "loc123", passport) - require.NoError(t, err) - - client.signer = signerMock{} - - return client -} - -type assertHandler func(http.ResponseWriter, *http.Request) (int, error) - -func hasReqBody(v interface{}) assertHandler { - return func(rw http.ResponseWriter, req *http.Request) (int, error) { - reqBody, err := io.ReadAll(req.Body) - if err != nil { - return http.StatusBadRequest, err - } - - marshal, err := json.Marshal(v) - if err != nil { - return http.StatusInternalServerError, err - } - - if !bytes.Equal(marshal, bytes.TrimSpace(reqBody)) { - return http.StatusBadRequest, fmt.Errorf("invalid request body, got: %s, expect: %s", string(reqBody), string(marshal)) - } - - return http.StatusOK, nil - } -} - -func respFromFile(fixtureName string) assertHandler { - return func(rw http.ResponseWriter, req *http.Request) (int, error) { - file, err := os.Open(filepath.Join(".", "fixtures", fixtureName)) - if err != nil { - return http.StatusInternalServerError, err - } - - _, err = io.Copy(rw, file) - if err != nil { - return http.StatusInternalServerError, err - } - - return http.StatusOK, nil - } -} diff --git a/providers/dns/hyperone/internal/passport.go b/providers/dns/hyperone/internal/passport.go index b63236c3b..d1503d893 100644 --- a/providers/dns/hyperone/internal/passport.go +++ b/providers/dns/hyperone/internal/passport.go @@ -25,6 +25,7 @@ func LoadPassportFile(location string) (*Passport, error) { defer func() { _ = file.Close() }() var passport Passport + err = json.NewDecoder(file).Decode(&passport) if err != nil { return nil, fmt.Errorf("failed to parse passport file: %w", err) diff --git a/providers/dns/hyperone/internal/token_test.go b/providers/dns/hyperone/internal/token_test.go index 243e015e8..34b4cc573 100644 --- a/providers/dns/hyperone/internal/token_test.go +++ b/providers/dns/hyperone/internal/token_test.go @@ -1,31 +1,18 @@ package internal import ( + "crypto/rand" + "crypto/rsa" "encoding/base64" "encoding/json" "strings" "testing" + "github.com/go-acme/lego/v4/certcrypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -const privateKey = `-----BEGIN RSA PRIVATE KEY----- -MIICWgIBAAKBgGFfgMY+DuO8l0RYrMLhcl6U/NigNIiOVhoo/xnYyoQALpWxBaBR -+iVJiBUYunQjKA33yAiY0AasCfSn1JB6asayQvGGn73xztLjkeCVLT+9e4nJ0A/o -dK8SOKBg9FFe70KJrWjJd626el0aVDJjtCE+QxJExA0UZbQp+XIyveQXAgMBAAEC -gYBHcL1XNWLRPaWx9GlUVfoGYMMd4HSKl/ueF+QKP59dt5B2LTnWhS7FOqzH5auu -17hkfx3ZCNzfeEuZn6T6F4bMtsQ6A5iT/DeRlG8tOPiCVZ/L0j6IFM78iIUT8XyA -miwnSy1xGSBA67yUmsLxFg2DtGCjamAkY0C5pccadaB7oQJBAKsIPpMXMni+Oo1I -kVxRyoIZgDxsMJiihG2YLVqo8rPtdErl+Lyg3ziVyg9KR6lFMaTBkYBTLoCPof3E -AB/jyucCQQCRv1cVnYNx+bfnXsBlcsCFDV2HkEuLTpxj7hauD4P3GcyLidSsUkn1 -PiPunZqKpsQaIoxc/BzTOCcP19ifgqdRAkBJ8Cp9FE4xfKt7YJ/WtVVCoRubA3qO -wdNWPa99vgQOXN0lc/3wLevSXo8XxRjtyIgJndT1EQDNe0qglhcnsiaJAkBziAcR -/VAq0tZys2szf6kYTyXqxfj8Lo5NsHeN9oKXJ346xkEtb/VsT5vQFGJishsU1HoL -Y1W+IO7l4iW3G6xhAkACNwtqxSRRbVsNCUMENpKmYhsyN8QXJ8V+o2A9s+pl21Kz -HIIm179mUYCgO6iAHmkqxlFHFwprUBKdPrmP8qF9 ------END RSA PRIVATE KEY-----` - type Header struct { Algorithm string `json:"alg"` Type string `json:"typ"` @@ -33,7 +20,10 @@ type Header struct { } func TestPayload_buildToken(t *testing.T) { - signer, err := getRSASigner(privateKey, "sampleKeyId") + key, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(t, err) + + signer, err := getRSASigner(string(certcrypto.PEMEncode(key)), "sampleKeyId") require.NoError(t, err) payload := Payload{IssuedAt: 1234, Expiry: 4321, Audience: "api.url", Issuer: "issuer", Subject: "subject"} @@ -48,6 +38,7 @@ func TestPayload_buildToken(t *testing.T) { require.NoError(t, err) var headerStruct Header + err = json.Unmarshal(headerString, &headerStruct) require.NoError(t, err) @@ -55,6 +46,7 @@ func TestPayload_buildToken(t *testing.T) { require.NoError(t, err) var payloadStruct Payload + err = json.Unmarshal(payloadString, &payloadStruct) require.NoError(t, err) diff --git a/providers/dns/ibmcloud/ibmcloud.go b/providers/dns/ibmcloud/ibmcloud.go index 82d817f19..81dec8e8b 100644 --- a/providers/dns/ibmcloud/ibmcloud.go +++ b/providers/dns/ibmcloud/ibmcloud.go @@ -6,6 +6,7 @@ import ( "fmt" "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/ibmcloud/internal" @@ -32,6 +33,8 @@ const ( EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Username string diff --git a/providers/dns/ibmcloud/ibmcloud.toml b/providers/dns/ibmcloud/ibmcloud.toml index 270995465..01088f09b 100644 --- a/providers/dns/ibmcloud/ibmcloud.toml +++ b/providers/dns/ibmcloud/ibmcloud.toml @@ -7,18 +7,18 @@ Since = "v4.5.0" Example = ''' SOFTLAYER_USERNAME=xxxxx \ SOFTLAYER_API_KEY=yyyyy \ -lego --email you@example.com --dns ibmcloud -d '*.example.com' -d example.com run +lego --dns ibmcloud -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] - SOFTLAYER_USERNAME = "Username (IBM Cloud is _)" + SOFTLAYER_USERNAME = "Username (IBM Cloud is {accountID}_{emailAddress})" SOFTLAYER_API_KEY = "Classic Infrastructure API key" [Configuration.Additional] - SOFTLAYER_POLLING_INTERVAL = "Time between DNS propagation check" - SOFTLAYER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - SOFTLAYER_TTL = "The TTL of the TXT record used for the DNS challenge" - SOFTLAYER_TIMEOUT = "API request timeout" + SOFTLAYER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + SOFTLAYER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + SOFTLAYER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + SOFTLAYER_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://cloud.ibm.com/docs/dns?topic=dns-getting-started-with-the-dns-api" diff --git a/providers/dns/ibmcloud/ibmcloud_test.go b/providers/dns/ibmcloud/ibmcloud_test.go index a000e3e59..6ca7cd81b 100644 --- a/providers/dns/ibmcloud/ibmcloud_test.go +++ b/providers/dns/ibmcloud/ibmcloud_test.go @@ -55,6 +55,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -127,6 +128,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -140,6 +142,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/iij/iij.go b/providers/dns/iij/iij.go index f5d0fdf9f..1d098bde2 100644 --- a/providers/dns/iij/iij.go +++ b/providers/dns/iij/iij.go @@ -6,13 +6,14 @@ import ( "fmt" "slices" "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/iij/doapi" "github.com/iij/doapi/protocol" + "github.com/miekg/dns" ) // Environment variables names. @@ -28,6 +29,8 @@ const ( EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { AccessKey string @@ -95,6 +98,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("iij: %w", err) } + return nil } @@ -107,6 +111,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("iij: %w", err) } + return nil } @@ -223,26 +228,20 @@ func (d *DNSProvider) listZones() ([]string, error) { } func splitDomain(domain string, zones []string) (string, string, error) { - parts := strings.Split(strings.Trim(domain, "."), ".") + base := dns01.UnFqdn(domain) - var owner string - var zone string + for _, index := range dns.Split(base) { + zone := base[index:] - for i := range len(parts) - 1 { - zone = strings.Join(parts[i:], ".") if slices.Contains(zones, zone) { - baseOwner := strings.Join(parts[0:i], ".") + baseOwner := base[:index] if baseOwner != "" { baseOwner = "." + baseOwner } - owner = "_acme-challenge" + baseOwner - break + + return "_acme-challenge" + dns01.UnFqdn(baseOwner), zone, nil } } - if owner == "" { - return "", "", fmt.Errorf("%s not found", domain) - } - - return owner, zone, nil + return "", "", fmt.Errorf("%s not found", domain) } diff --git a/providers/dns/iij/iij.toml b/providers/dns/iij/iij.toml index da7590dd9..95355200a 100644 --- a/providers/dns/iij/iij.toml +++ b/providers/dns/iij/iij.toml @@ -8,7 +8,7 @@ Example = ''' IIJ_API_ACCESS_KEY=xxxxxxxx \ IIJ_API_SECRET_KEY=yyyyyy \ IIJ_DO_SERVICE_CODE=zzzzzz \ -lego --email you@example.com --dns iij -d '*.example.com' -d example.com run +lego --dns iij -d '*.example.com' -d example.com run ''' [Configuration] @@ -17,9 +17,9 @@ lego --email you@example.com --dns iij -d '*.example.com' -d example.com run IIJ_API_SECRET_KEY = "API secret key" IIJ_DO_SERVICE_CODE = "DO service code" [Configuration.Additional] - IIJ_POLLING_INTERVAL = "Time between DNS propagation check" - IIJ_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - IIJ_TTL = "The TTL of the TXT record used for the DNS challenge" + IIJ_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)" + IIJ_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 240)" + IIJ_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" [Links] API = "https://manual.iij.jp/p2/pubapi/" diff --git a/providers/dns/iij/iij_test.go b/providers/dns/iij/iij_test.go index 936dd9b8d..bd8140532 100644 --- a/providers/dns/iij/iij_test.go +++ b/providers/dns/iij/iij_test.go @@ -71,6 +71,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -161,31 +162,31 @@ func TestSplitDomain(t *testing.T) { }{ { desc: "domain equals zone", - domain: "domain.com", - zones: []string{"domain.com"}, + domain: "example.com", + zones: []string{"example.com"}, expectedOwner: "_acme-challenge", - expectedZone: "domain.com", + expectedZone: "example.com", }, { desc: "with a subdomain", - domain: "my.domain.com", - zones: []string{"domain.com"}, + domain: "my.example.com", + zones: []string{"example.com"}, expectedOwner: "_acme-challenge.my", - expectedZone: "domain.com", + expectedZone: "example.com", }, { desc: "with a subdomain in a zone", - domain: "my.sub.domain.com", - zones: []string{"sub.domain.com", "domain.com"}, + domain: "my.sub.example.com", + zones: []string{"sub.example.com", "example.com"}, expectedOwner: "_acme-challenge.my", - expectedZone: "sub.domain.com", + expectedZone: "sub.example.com", }, { desc: "with a sub-subdomain", - domain: "my.sub.domain.com", - zones: []string{"domain1.com", "domain.com"}, + domain: "my.sub.example.com", + zones: []string{"domain1.com", "example.com"}, expectedOwner: "_acme-challenge.my.sub", - expectedZone: "domain.com", + expectedZone: "example.com", }, } @@ -202,12 +203,43 @@ func TestSplitDomain(t *testing.T) { } } +func TestSplitDomain_error(t *testing.T) { + testCases := []struct { + desc string + domain string + zones []string + expectedOwner string + expectedZone string + }{ + { + desc: "no zone", + domain: "example.com", + zones: nil, + }, + { + desc: "domain does not contain zone", + domain: "example.com", + zones: []string{"example.org"}, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + _, _, err := splitDomain(test.domain, test.zones) + require.Error(t, err) + }) + } +} + func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -221,6 +253,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/iijdpf/iijdpf.go b/providers/dns/iijdpf/iijdpf.go index a703aeaf2..2a626e889 100644 --- a/providers/dns/iijdpf/iijdpf.go +++ b/providers/dns/iijdpf/iijdpf.go @@ -28,6 +28,8 @@ const ( EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Token string @@ -49,8 +51,6 @@ func NewDefaultConfig() *Config { } } -var _ challenge.Provider = &DNSProvider{} - // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client dpfapi.ClientInterface diff --git a/providers/dns/iijdpf/iijdpf.toml b/providers/dns/iijdpf/iijdpf.toml index 297866e2b..650285f95 100644 --- a/providers/dns/iijdpf/iijdpf.toml +++ b/providers/dns/iijdpf/iijdpf.toml @@ -7,7 +7,7 @@ Since = "v4.7.0" Example = ''' IIJ_DPF_API_TOKEN=xxxxxxxx \ IIJ_DPF_DPM_SERVICE_CODE=yyyyyy \ -lego --email you@example.com --dns iijdpf -d '*.example.com' -d example.com run +lego --dns iijdpf -d '*.example.com' -d example.com run ''' [Configuration] @@ -16,9 +16,9 @@ lego --email you@example.com --dns iijdpf -d '*.example.com' -d example.com run IIJ_DPF_DPM_SERVICE_CODE = "IIJ Managed DNS Service's service code" [Configuration.Additional] IIJ_DPF_API_ENDPOINT = "API endpoint URL, defaults to https://api.dns-platform.jp/dpf/v1" - IIJ_DPF_POLLING_INTERVAL = "Time between DNS propagation check, defaults to 5 second" - IIJ_DPF_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation, defaults to 660 second" - IIJ_DPF_TTL = "The TTL of the TXT record used for the DNS challenge, default to 300" + IIJ_DPF_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" + IIJ_DPF_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 660)" + IIJ_DPF_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" [Links] API = "https://manual.iij.jp/dpf/dpfapi/" diff --git a/providers/dns/iijdpf/iijdpf_test.go b/providers/dns/iijdpf/iijdpf_test.go index a4fa8b8f6..fbcf3e1f5 100644 --- a/providers/dns/iijdpf/iijdpf_test.go +++ b/providers/dns/iijdpf/iijdpf_test.go @@ -43,6 +43,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -115,6 +116,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -128,6 +130,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/iijdpf/wrapper.go b/providers/dns/iijdpf/wrapper.go index 12b09a30c..0ab26cdcd 100644 --- a/providers/dns/iijdpf/wrapper.go +++ b/providers/dns/iijdpf/wrapper.go @@ -51,6 +51,7 @@ func (d *DNSProvider) deleteTxtRecord(ctx context.Context, zoneID, fqdn, rdata s // empty target rrset return nil } + return err } @@ -66,11 +67,13 @@ func (d *DNSProvider) deleteTxtRecord(ctx context.Context, zoneID, fqdn, rdata s // delete rdata rdataSlice := dpfzones.RecordRDATASlice{} + for _, v := range r.RData { if v.Value != rdata { rdataSlice = append(rdataSlice, v) } } + r.RData = rdataSlice _, _, err = dpfapiutils.SyncUpdate(ctx, d.client, r, nil) diff --git a/providers/dns/infoblox/infoblox.go b/providers/dns/infoblox/infoblox.go index 87a443e44..054f13679 100644 --- a/providers/dns/infoblox/infoblox.go +++ b/providers/dns/infoblox/infoblox.go @@ -8,23 +8,25 @@ import ( "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/useragent" - infoblox "github.com/infobloxopen/infoblox-go-client" + infoblox "github.com/infobloxopen/infoblox-go-client/v2" ) // Environment variables names. const ( envNamespace = "INFOBLOX_" - EnvHost = envNamespace + "HOST" - EnvPort = envNamespace + "PORT" - EnvUsername = envNamespace + "USERNAME" - EnvPassword = envNamespace + "PASSWORD" - EnvDNSView = envNamespace + "DNS_VIEW" - EnvWApiVersion = envNamespace + "WAPI_VERSION" - EnvSSLVerify = envNamespace + "SSL_VERIFY" + EnvHost = envNamespace + "HOST" + EnvPort = envNamespace + "PORT" + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "PASSWORD" + EnvDNSView = envNamespace + "DNS_VIEW" + EnvWApiVersion = envNamespace + "WAPI_VERSION" + EnvSSLVerify = envNamespace + "SSL_VERIFY" + EnvCACertificate = envNamespace + "CA_CERTIFICATE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" @@ -34,6 +36,8 @@ const ( const defaultPoolConnections = 10 +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { // Host is the URL of the grid manager. @@ -54,6 +58,9 @@ type Config struct { // SSLVerify is whether or not to verify the ssl of the server being hit. SSLVerify bool + // CACertificate is the path to the CA certificate (PEM encoded). + CACertificate string + PropagationTimeout time.Duration PollingInterval time.Duration TTL int @@ -63,10 +70,11 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - DNSView: env.GetOrDefaultString(EnvDNSView, "External"), - WapiVersion: env.GetOrDefaultString(EnvWApiVersion, "2.11"), - Port: env.GetOrDefaultString(EnvPort, "443"), - SSLVerify: env.GetOrDefaultBool(EnvSSLVerify, true), + DNSView: env.GetOrDefaultString(EnvDNSView, "External"), + WapiVersion: env.GetOrDefaultString(EnvWApiVersion, "2.11"), + Port: env.GetOrDefaultString(EnvPort, "443"), + SSLVerify: env.GetOrDefaultBool(EnvSSLVerify, true), + CACertificate: env.GetOrDefaultString(EnvCACertificate, ""), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), @@ -80,6 +88,7 @@ type DNSProvider struct { config *Config transportConfig infoblox.TransportConfig ibConfig infoblox.HostConfig + ibAuth infoblox.AuthConfig recordRefs map[string]string recordRefsMu sync.Mutex @@ -119,13 +128,22 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("infoblox: missing credentials") } + var sslVerify string + if config.CACertificate != "" { + sslVerify = config.CACertificate + } else { + sslVerify = strconv.FormatBool(config.SSLVerify) + } + return &DNSProvider{ config: config, - transportConfig: infoblox.NewTransportConfig(strconv.FormatBool(config.SSLVerify), config.HTTPTimeout, defaultPoolConnections), + transportConfig: infoblox.NewTransportConfig(sslVerify, config.HTTPTimeout, defaultPoolConnections), ibConfig: infoblox.HostConfig{ - Host: config.Host, - Version: config.WapiVersion, - Port: config.Port, + Host: config.Host, + Version: config.WapiVersion, + Port: config.Port, + }, + ibAuth: infoblox.AuthConfig{ Username: config.Username, Password: config.Password, }, @@ -142,7 +160,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - connector, err := infoblox.NewConnector(d.ibConfig, d.transportConfig, &infoblox.WapiRequestBuilder{}, &infoblox.WapiHttpRequestor{}) + connector, err := infoblox.NewConnector(d.ibConfig, d.ibAuth, d.transportConfig, &infoblox.WapiRequestBuilder{}, &infoblox.WapiHttpRequestor{}) if err != nil { return fmt.Errorf("infoblox: %w", err) } @@ -151,7 +169,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { objectManager := infoblox.NewObjectManager(connector, useragent.Get(), "") - record, err := objectManager.CreateTXTRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value, uint(d.config.TTL), d.config.DNSView) + record, err := objectManager.CreateTXTRecord(d.config.DNSView, dns01.UnFqdn(info.EffectiveFQDN), info.Value, uint32(d.config.TTL), true, "lego", nil) if err != nil { return fmt.Errorf("infoblox: could not create TXT record for %s: %w", domain, err) } @@ -167,7 +185,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - connector, err := infoblox.NewConnector(d.ibConfig, d.transportConfig, &infoblox.WapiRequestBuilder{}, &infoblox.WapiHttpRequestor{}) + connector, err := infoblox.NewConnector(d.ibConfig, d.ibAuth, d.transportConfig, &infoblox.WapiRequestBuilder{}, &infoblox.WapiHttpRequestor{}) if err != nil { return fmt.Errorf("infoblox: %w", err) } @@ -180,6 +198,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordRefsMu.Lock() recordRef, ok := d.recordRefs[token] d.recordRefsMu.Unlock() + if !ok { return fmt.Errorf("infoblox: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } diff --git a/providers/dns/infoblox/infoblox.toml b/providers/dns/infoblox/infoblox.toml index ad7cb5cef..0e6972d3a 100644 --- a/providers/dns/infoblox/infoblox.toml +++ b/providers/dns/infoblox/infoblox.toml @@ -8,7 +8,7 @@ Example = ''' INFOBLOX_USERNAME=api-user-529 \ INFOBLOX_PASSWORD=b9841238feb177a84330febba8a83208921177bffe733 \ INFOBLOX_HOST=infoblox.example.org -lego --email you@example.com --dns infoblox -d '*.example.com' -d example.com run +lego --dns infoblox -d '*.example.com' -d example.com run ''' Additional = ''' @@ -21,14 +21,15 @@ When creating an API's user ensure it has the proper permissions for the view yo INFOBLOX_PASSWORD = "Account Password" INFOBLOX_HOST = "Host URI" [Configuration.Additional] - INFOBLOX_DNS_VIEW = "The view for the TXT records, default: External" - INFOBLOX_WAPI_VERSION = "The version of WAPI being used, default: 2.11" - INFOBLOX_PORT = "The port for the infoblox grid manager, default: 443" - INFOBLOX_SSL_VERIFY = "Whether or not to verify the TLS certificate, default: true" - INFOBLOX_POLLING_INTERVAL = "Time between DNS propagation check" - INFOBLOX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - INFOBLOX_TTL = "The TTL of the TXT record used for the DNS challenge" - INFOBLOX_HTTP_TIMEOUT = "HTTP request timeout" + INFOBLOX_DNS_VIEW = "The view for the TXT records (Default: External)" + INFOBLOX_WAPI_VERSION = "The version of WAPI being used (Default: 2.11)" + INFOBLOX_PORT = "The port for the infoblox grid manager (Default: 443)" + INFOBLOX_SSL_VERIFY = "Whether or not to verify the TLS certificate (Default: true)" + INFOBLOX_CA_CERTIFICATE = "The path to the CA certificate (PEM encoded)" + INFOBLOX_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + INFOBLOX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + INFOBLOX_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + INFOBLOX_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] diff --git a/providers/dns/infoblox/infoblox_test.go b/providers/dns/infoblox/infoblox_test.go index 45434e0e3..68158cb0d 100644 --- a/providers/dns/infoblox/infoblox_test.go +++ b/providers/dns/infoblox/infoblox_test.go @@ -68,6 +68,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -149,6 +150,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -162,6 +164,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/infomaniak/infomaniak.go b/providers/dns/infomaniak/infomaniak.go index 53d98c4f4..9b8b53590 100644 --- a/providers/dns/infomaniak/infomaniak.go +++ b/providers/dns/infomaniak/infomaniak.go @@ -9,9 +9,11 @@ import ( "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/infomaniak/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Infomaniak API reference: https://api.infomaniak.com/doc @@ -30,6 +32,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIEndpoint string @@ -44,9 +48,9 @@ type Config struct { func NewDefaultConfig() *Config { return &Config{ APIEndpoint: env.GetOrDefaultString(EnvEndpoint, internal.DefaultBaseURL), - TTL: env.GetOrDefaultInt(EnvTTL, 7200), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + TTL: env.GetOrDefaultInt(EnvTTL, 300), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, @@ -93,7 +97,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("infomaniak: missing access token") } - client, err := internal.New(internal.OAuthStaticAccessToken(config.HTTPClient, config.AccessToken), config.APIEndpoint) + client, err := internal.New( + clientdebug.Wrap( + internal.OAuthStaticAccessToken(config.HTTPClient, config.AccessToken), + ), + config.APIEndpoint) if err != nil { return nil, fmt.Errorf("infomaniak: %w", err) } diff --git a/providers/dns/infomaniak/infomaniak.toml b/providers/dns/infomaniak/infomaniak.toml index 2de205b8f..d924e3a26 100644 --- a/providers/dns/infomaniak/infomaniak.toml +++ b/providers/dns/infomaniak/infomaniak.toml @@ -6,7 +6,7 @@ Since = "v4.1.0" Example = ''' INFOMANIAK_ACCESS_TOKEN=1234567898765432 \ -lego --email you@example.com --dns infomaniak -d '*.example.com' -d example.com run +lego --dns infomaniak -d '*.example.com' -d example.com run ''' Additional = ''' @@ -21,10 +21,10 @@ You will need domain scope. INFOMANIAK_ACCESS_TOKEN = "Access token" [Configuration.Additional] INFOMANIAK_ENDPOINT = "https://api.infomaniak.com" - INFOMANIAK_POLLING_INTERVAL = "Time between DNS propagation check" - INFOMANIAK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - INFOMANIAK_TTL = "The TTL of the TXT record used for the DNS challenge in seconds" - INFOMANIAK_HTTP_TIMEOUT = "API request timeout" + INFOMANIAK_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + INFOMANIAK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + INFOMANIAK_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + INFOMANIAK_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.infomaniak.com/doc" diff --git a/providers/dns/infomaniak/infomaniak_test.go b/providers/dns/infomaniak/infomaniak_test.go index bc8fb7b58..980f3b959 100644 --- a/providers/dns/infomaniak/infomaniak_test.go +++ b/providers/dns/infomaniak/infomaniak_test.go @@ -39,6 +39,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -101,6 +102,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -114,6 +116,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/infomaniak/internal/client.go b/providers/dns/infomaniak/internal/client.go index 886a8966f..40b56c707 100644 --- a/providers/dns/infomaniak/internal/client.go +++ b/providers/dns/infomaniak/internal/client.go @@ -50,6 +50,7 @@ func (c *Client) CreateDNSRecord(ctx context.Context, domain *DNSDomain, record } result := APIResponse[string]{} + err = c.do(req, &result) if err != nil { return "", err @@ -112,6 +113,7 @@ func (c *Client) getDomainByName(ctx context.Context, name string) (*DNSDomain, } result := APIResponse[[]DNSDomain]{} + err = c.do(req, &result) if err != nil { return nil, err diff --git a/providers/dns/infomaniak/internal/client_test.go b/providers/dns/infomaniak/internal/client_test.go index 4fadaf0f5..d846f06b4 100644 --- a/providers/dns/infomaniak/internal/client_test.go +++ b/providers/dns/infomaniak/internal/client_test.go @@ -1,65 +1,34 @@ package internal import ( - "bytes" - "context" - "fmt" - "io" - "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := New(OAuthStaticAccessToken(server.Client(), "token"), server.URL) + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client, err := New(OAuthStaticAccessToken(server.Client(), "token"), server.URL) - require.NoError(t, err) - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer token")) } func TestClient_CreateDNSRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/1/domain/666/dns/record", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - if req.Header.Get("Authorization") != "Bearer token" { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - raw, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - defer func() { _ = req.Body.Close() }() - - if string(bytes.TrimSpace(raw)) != `{"source":"foo","type":"TXT","ttl":60,"target":"txtxtxttxt"}` { - http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest) - return - } - - response := `{"result":"success","data": "123"}` - - _, err = rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /1/domain/666/dns/record", + servermock.RawStringResponse(`{"result":"success","data": "123"}`), + servermock.CheckRequestJSONBodyFromFixture("create_dns_record-request.json")). + Build(t) domain := &DNSDomain{ ID: 666, @@ -73,62 +42,22 @@ func TestClient_CreateDNSRecord(t *testing.T) { TTL: 60, } - recordID, err := client.CreateDNSRecord(context.Background(), domain, record) + recordID, err := client.CreateDNSRecord(t.Context(), domain, record) require.NoError(t, err) assert.Equal(t, "123", recordID) } func TestClient_GetDomainByName(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /1/product", + servermock.ResponseFromFixture("get_domain_name.json"), + servermock.CheckQueryParameter().Strict(). + WithRegexp("customer_name", `.+\.example\.com`). + With("service_name", "domain")). + Build(t) - mux.HandleFunc("/1/product", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - if req.Header.Get("Authorization") != "Bearer token" { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - serviceName := req.URL.Query().Get("service_name") - if serviceName != "domain" { - http.Error(rw, fmt.Sprintf("invalid service_name: %s", serviceName), http.StatusBadRequest) - return - } - - customerName := req.URL.Query().Get("customer_name") - if customerName == "" { - http.Error(rw, fmt.Sprintf("invalid customer_name: %s", customerName), http.StatusBadRequest) - return - } - - response := ` - { - "result": "success", - "data": [ - { - "id": 123, - "customer_name": "two.three.example.com" - }, - { - "id": 456, - "customer_name": "three.example.com" - } - ] - } - ` - - _, err := rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - domain, err := client.GetDomainByName(context.Background(), "one.two.three.example.com.") + domain, err := client.GetDomainByName(t.Context(), "one.two.three.example.com.") require.NoError(t, err) expected := &DNSDomain{ID: 123, CustomerName: "two.three.example.com"} @@ -136,26 +65,11 @@ func TestClient_GetDomainByName(t *testing.T) { } func TestClient_DeleteDNSRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /1/domain/123/dns/record/456", + servermock.RawStringResponse(`{"result":"success"}`)). + Build(t) - mux.HandleFunc("/1/domain/123/dns/record/456", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - if req.Header.Get("Authorization") != "Bearer token" { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - _, err := rw.Write([]byte((`{"result":"success"}`))) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - err := client.DeleteDNSRecord(context.Background(), 123, "456") + err := client.DeleteDNSRecord(t.Context(), 123, "456") require.NoError(t, err) } diff --git a/providers/dns/infomaniak/internal/fixtures/create_dns_record-request.json b/providers/dns/infomaniak/internal/fixtures/create_dns_record-request.json new file mode 100644 index 000000000..7e00434f1 --- /dev/null +++ b/providers/dns/infomaniak/internal/fixtures/create_dns_record-request.json @@ -0,0 +1,6 @@ +{ + "source": "foo", + "type": "TXT", + "ttl": 60, + "target": "txtxtxttxt" +} diff --git a/providers/dns/infomaniak/internal/fixtures/get_domain_name.json b/providers/dns/infomaniak/internal/fixtures/get_domain_name.json new file mode 100644 index 000000000..d431cc0d7 --- /dev/null +++ b/providers/dns/infomaniak/internal/fixtures/get_domain_name.json @@ -0,0 +1,13 @@ +{ + "result": "success", + "data": [ + { + "id": 123, + "customer_name": "two.three.example.com" + }, + { + "id": 456, + "customer_name": "three.example.com" + } + ] +} diff --git a/providers/dns/websupport/internal/client.go b/providers/dns/internal/active24/internal/client.go similarity index 53% rename from providers/dns/websupport/internal/client.go rename to providers/dns/internal/active24/internal/client.go index 4fef0be91..69e94b367 100644 --- a/providers/dns/websupport/internal/client.go +++ b/providers/dns/internal/active24/internal/client.go @@ -12,147 +12,118 @@ import ( "io" "net/http" "net/url" - "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) -const defaultBaseURL = "https://rest.websupport.sk" +const defaultBaseURL = "https://rest.%s" -// StatusSuccess expected status text when success. -const StatusSuccess = "success" - -// Client a Websupport DNS API client. +// Client the Active24 API client. type Client struct { - apiKey string - secretKey string + apiKey string + secret string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. -func NewClient(apiKey, secretKey string) (*Client, error) { - if apiKey == "" || secretKey == "" { +func NewClient(baseAPIDomain, apiKey, secret string) (*Client, error) { + if apiKey == "" || secret == "" { return nil, errors.New("credentials missing") } - baseURL, _ := url.Parse(defaultBaseURL) + baseURL, _ := url.Parse(fmt.Sprintf(defaultBaseURL, baseAPIDomain)) return &Client{ apiKey: apiKey, - secretKey: secretKey, + secret: secret, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } -// GetUser gets a user detail. -// https://rest.websupport.sk/docs/v1.user#user -func (c *Client) GetUser(ctx context.Context, userID string) (*User, error) { - endpoint := c.baseURL.JoinPath("v1", "user", userID) - - req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, fmt.Errorf("request payload: %w", err) - } - - result := &User{} - - err = c.do(req, result) - if err != nil { - return nil, err - } - - return result, nil -} - -// ListRecords lists all records. -// https://rest.websupport.sk/docs/v1.zone#records -func (c *Client) ListRecords(ctx context.Context, domainName string) (*ListResponse, error) { - endpoint := c.baseURL.JoinPath("v1", "user", "self", "zone", domainName, "record") - - req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, fmt.Errorf("request payload: %w", err) - } - - result := &ListResponse{} - - err = c.do(req, result) - if err != nil { - return nil, err - } - - return result, nil -} - -// GetRecords gets a DNS record. -func (c *Client) GetRecords(ctx context.Context, domainName string, recordID int) (*Record, error) { - endpoint := c.baseURL.JoinPath("v1", "user", "self", "zone", domainName, "record", strconv.Itoa(recordID)) +// GetServices lists of all services. +// https://rest.active24.cz/docs/v1.service#services +func (c *Client) GetServices(ctx context.Context) ([]Service, error) { + endpoint := c.baseURL.JoinPath("v1", "user", "self", "service") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } - result := &Record{} + var result OldAPIResponse - err = c.do(req, result) + err = c.do(req, &result) if err != nil { return nil, err } - return result, nil + return result.Items, err } -// AddRecord adds a DNS record. -// https://rest.websupport.sk/docs/v1.zone#post-record -func (c *Client) AddRecord(ctx context.Context, domainName string, record Record) (*Response, error) { - endpoint := c.baseURL.JoinPath("v1", "user", "self", "zone", domainName, "record") +// GetRecords lists of DNS records. +// https://rest.active24.cz/v2/docs#/DNS/rest.v2.dns.record_f94908d4e0e48489468498fce87cb90b +func (c *Client) GetRecords(ctx context.Context, service string, filter RecordFilter) ([]Record, error) { + endpoint := c.baseURL.JoinPath("v2", "service", service, "dns", "record") + + encodedFilter, err := json.Marshal(filter) + if err != nil { + return nil, fmt.Errorf("marshal records filter: %w", err) + } + + query := endpoint.Query() + query.Add("filters", string(encodedFilter)) + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result APIResponse + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result.Data, err +} + +// CreateRecord creates a new DNS record. +// https://rest.active24.cz/v2/docs#/DNS/rest.v2.dns.create-record_6773d572235be9a72646bf6c54863573 +func (c *Client) CreateRecord(ctx context.Context, service string, record Record) error { + endpoint := c.baseURL.JoinPath("v2", "service", service, "dns", "record") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { - return nil, fmt.Errorf("create request: %w", err) + return err } - result := &Response{} - - err = c.do(req, result) - if err != nil { - return nil, err - } - - return result, nil + return c.do(req, nil) } // DeleteRecord deletes a DNS record. -// https://rest.websupport.sk/docs/v1.zone#delete-record -func (c *Client) DeleteRecord(ctx context.Context, domainName string, recordID int) (*Response, error) { - endpoint := c.baseURL.JoinPath("v1", "user", "self", "zone", domainName, "record", strconv.Itoa(recordID)) +// https://rest.active24.cz/v2/docs#/DNS/rest.v2.dns.delete-record_fc6603c14848e547f8d0b967842f0a2c +func (c *Client) DeleteRecord(ctx context.Context, service, recordID string) error { + endpoint := c.baseURL.JoinPath("v2", "service", service, "dns", "record", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { - return nil, fmt.Errorf("create request: %w", err) + return err } - result := &Response{} - - err = c.do(req, result) - if err != nil { - return nil, err - } - - return result, nil + return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { req.Header.Set("Accept-Language", "en_us") - err := c.sign(req, time.Now().UTC()) + err := c.sign(req, time.Now()) if err != nil { - return fmt.Errorf("signature: %w", err) + return fmt.Errorf("sign request: %w", err) } resp, err := c.HTTPClient.Do(req) @@ -162,10 +133,14 @@ func (c *Client) do(req *http.Request, result any) error { defer func() { _ = resp.Body.Close() }() - if resp.StatusCode > http.StatusBadRequest { + if resp.StatusCode/100 != 2 { return parseError(req, resp) } + if result == nil { + return nil + } + raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) @@ -179,29 +154,6 @@ func (c *Client) do(req *http.Request, result any) error { return nil } -func (c *Client) sign(req *http.Request, now time.Time) error { - if req.URL.Path == "" { - req.URL.Path += "/" - } - - canonicalRequest := fmt.Sprintf("%s %s %d", req.Method, req.URL.Path, now.Unix()) - - mac := hmac.New(sha1.New, []byte(c.secretKey)) - _, err := mac.Write([]byte(canonicalRequest)) - if err != nil { - return err - } - - hashed := mac.Sum(nil) - signature := hex.EncodeToString(hashed) - - req.SetBasicAuth(c.apiKey, signature) - - req.Header.Set("Date", now.Format(time.RFC3339)) - - return nil -} - func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) @@ -230,6 +182,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError + err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) @@ -237,3 +190,29 @@ func parseError(req *http.Request, resp *http.Response) error { return &errAPI } + +// sign creates and sets request signature and date. +// https://rest.active24.cz/v2/docs/intro +func (c *Client) sign(req *http.Request, now time.Time) error { + if req.URL.Path == "" { + req.URL.Path += "/" + } + + canonicalRequest := fmt.Sprintf("%s %s %d", req.Method, req.URL.Path, now.Unix()) + + mac := hmac.New(sha1.New, []byte(c.secret)) + + _, err := mac.Write([]byte(canonicalRequest)) + if err != nil { + return err + } + + hashed := mac.Sum(nil) + signature := hex.EncodeToString(hashed) + + req.SetBasicAuth(c.apiKey, signature) + + req.Header.Set("Date", now.Format(time.RFC3339)) + + return nil +} diff --git a/providers/dns/internal/active24/internal/client_test.go b/providers/dns/internal/active24/internal/client_test.go new file mode 100644 index 000000000..f62f78785 --- /dev/null +++ b/providers/dns/internal/active24/internal/client_test.go @@ -0,0 +1,182 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("example.com", "user", "secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithRegexp("Authorization", `Basic .+`). + WithRegexp("Date", `\d+-\d+-\d+T\d{2}:\d{2}:\d{2}.*`). + With("Accept-Language", "en_us")) +} + +func TestClient_GetServices(t *testing.T) { + client := mockBuilder(). + Route("GET /v1/user/self/service", + servermock.ResponseFromFixture("services.json")). + Build(t) + + services, err := client.GetServices(t.Context()) + require.NoError(t, err) + + expected := []Service{ + { + ID: 1111, + ServiceName: ".sk doména", + Status: "active", + Name: "mydomain.sk", + CreateTime: 1374357600, + ExpireTime: 1405914526, + Price: 12.3, + }, + { + ID: 2222, + ServiceName: "The Hosting", + Status: "active", + Name: "myname_1", + CreateTime: 1400145443, + ExpireTime: 1431702371, + Price: 55.2, + }, + } + + assert.Equal(t, expected, services) +} + +func TestClient_GetServices_errors(t *testing.T) { + client := mockBuilder(). + Route("GET /v1/user/self/service", + servermock.ResponseFromFixture("error_v1.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + _, err := client.GetServices(t.Context()) + require.EqualError(t, err, "401: No username or password.") +} + +func TestClient_GetRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /v2/service/aaa/dns/record", + servermock.ResponseFromFixture("records.json")). + Build(t) + + filter := RecordFilter{ + Name: "example.com", + Type: []string{"TXT"}, + Content: "txt", + } + + records, err := client.GetRecords(t.Context(), "aaa", filter) + require.NoError(t, err) + + expected := []Record{{ + ID: 13, + Name: "string", + Content: "string", + TTL: 120, + Priority: 1, + Port: 443, + Weight: 50, + }} + + assert.Equal(t, expected, records) +} + +func TestClient_GetRecords_errors(t *testing.T) { + client := mockBuilder(). + Route("GET /v2/service/aaa/dns/record", + servermock.ResponseFromFixture("error_403.json"). + WithStatusCode(http.StatusForbidden)). + Build(t) + + filter := RecordFilter{ + Name: "example.com", + Type: []string{"TXT"}, + Content: "txt", + } + + _, err := client.GetRecords(t.Context(), "aaa", filter) + require.EqualError(t, err, "403: /errors/httpException: This action is unauthorized.") +} + +func TestClient_CreateRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /v2/service/aaa/dns/record", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) + + err := client.CreateRecord(t.Context(), "aaa", Record{}) + require.NoError(t, err) +} + +func TestClient_CreateRecord_errors(t *testing.T) { + client := mockBuilder(). + Route("POST /v2/service/aaa/dns/record", + servermock.ResponseFromFixture("error_403.json"). + WithStatusCode(http.StatusForbidden)). + Build(t) + + err := client.CreateRecord(t.Context(), "aaa", Record{}) + require.EqualError(t, err, "403: /errors/httpException: This action is unauthorized.") +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /v2/service/aaa/dns/record/123", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) + + err := client.DeleteRecord(t.Context(), "aaa", "123") + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /v2/service/aaa/dns/record/123", + servermock.ResponseFromFixture("error_403.json"). + WithStatusCode(http.StatusForbidden)). + Build(t) + + err := client.DeleteRecord(t.Context(), "aaa", "123") + require.EqualError(t, err, "403: /errors/httpException: This action is unauthorized.") +} + +func TestClient_sign(t *testing.T) { + client, err := NewClient("example.com", "user", "secret") + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodGet, "/v1/user/self/service", nil) + require.NoError(t, err) + + err = client.sign(req, time.Date(2025, 6, 28, 1, 2, 3, 4, time.UTC)) + require.NoError(t, err) + + username, password, ok := req.BasicAuth() + require.True(t, ok) + + assert.Equal(t, "user", username) + assert.Equal(t, "743e2257421b260ed561f3e7af4b035414636393", password) +} diff --git a/providers/dns/internal/active24/internal/fixtures/error_403.json b/providers/dns/internal/active24/internal/fixtures/error_403.json new file mode 100644 index 000000000..ee3ce196e --- /dev/null +++ b/providers/dns/internal/active24/internal/fixtures/error_403.json @@ -0,0 +1,5 @@ +{ + "type": "/errors/httpException", + "status": 403, + "title": "This action is unauthorized." +} diff --git a/providers/dns/internal/active24/internal/fixtures/error_422.json b/providers/dns/internal/active24/internal/fixtures/error_422.json new file mode 100644 index 000000000..0864a1fce --- /dev/null +++ b/providers/dns/internal/active24/internal/fixtures/error_422.json @@ -0,0 +1,16 @@ +{ + "type": "/errors/validation", + "status": 422, + "title": "The given data was invalid.", + "violations": [ + { + "propertyPath": "string", + "errors": [ + {} + ] + } + ], + "data": { + "name": "Merlin" + } +} diff --git a/providers/dns/internal/active24/internal/fixtures/error_v1.json b/providers/dns/internal/active24/internal/fixtures/error_v1.json new file mode 100644 index 000000000..8043412e5 --- /dev/null +++ b/providers/dns/internal/active24/internal/fixtures/error_v1.json @@ -0,0 +1,4 @@ +{ + "message": "No username or password.", + "code": 401 +} diff --git a/providers/dns/internal/active24/internal/fixtures/records.json b/providers/dns/internal/active24/internal/fixtures/records.json new file mode 100644 index 000000000..bf07d9ef7 --- /dev/null +++ b/providers/dns/internal/active24/internal/fixtures/records.json @@ -0,0 +1,28 @@ +{ + "currentPage": 0, + "rowsPerPage": 0, + "totalPages": 0, + "totalRecords": 0, + "actions": { + "additionalProp1": { + "additionalProp1": {} + }, + "additionalProp2": { + "additionalProp1": {} + }, + "additionalProp3": { + "additionalProp1": {} + } + }, + "data": [ + { + "id": 13, + "name": "string", + "content": "string", + "ttl": 120, + "priority": 1, + "port": 443, + "weight": 50 + } + ] +} diff --git a/providers/dns/internal/active24/internal/fixtures/services.json b/providers/dns/internal/active24/internal/fixtures/services.json new file mode 100644 index 000000000..ad9b28700 --- /dev/null +++ b/providers/dns/internal/active24/internal/fixtures/services.json @@ -0,0 +1,31 @@ +{ + "items": + [ + { + "id": 1111, + "serviceName": ".sk doména", + "status": "active", + "name": "mydomain.sk", + "createTime": 1374357600, + "expireTime": 1405914526, + "price": 12.3, + "autoExtend": false + }, + { + "id": 2222, + "serviceName": "The Hosting", + "status": "active", + "name": "myname_1", + "createTime": 1400145443, + "expireTime": 1431702371, + "price": 55.2, + "autoExtend": false + } + ], + "pager": + { + "page": 1, + "pagesize": null, + "items": 2 + } +} diff --git a/providers/dns/internal/active24/internal/types.go b/providers/dns/internal/active24/internal/types.go new file mode 100644 index 000000000..ed8dfc9d3 --- /dev/null +++ b/providers/dns/internal/active24/internal/types.go @@ -0,0 +1,65 @@ +package internal + +import "fmt" + +type APIError struct { + // v2 error + Type string `json:"type,omitempty"` + Status int `json:"status,omitempty"` + Title string `json:"title,omitempty"` + + // v1 error + Message string `json:"message,omitempty"` + Code int `json:"code,omitempty"` +} + +func (a *APIError) Error() string { + if a.Message != "" { + return fmt.Sprintf("%d: %s", a.Code, a.Message) + } + + return fmt.Sprintf("%d: %s: %s", a.Status, a.Type, a.Title) +} + +type APIResponse struct { + Data []Record `json:"data"` +} + +type Record struct { + ID int `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Content string `json:"content,omitempty"` + TTL int `json:"ttl,omitempty"` + Priority int `json:"priority,omitempty"` + Port int `json:"port,omitempty"` + Weight int `json:"weight,omitempty"` +} + +type OldAPIResponse struct { + Items []Service `json:"items"` +} + +type Service struct { + ID int `json:"id,omitempty"` + ServiceName string `json:"serviceName,omitempty"` + Status string `json:"status,omitempty"` + Name string `json:"name,omitempty"` + CreateTime int `json:"createTime,omitempty"` + ExpireTime int `json:"expireTime,omitempty"` + Price float64 `json:"price,omitempty"` + AutoExtend bool `json:"autoExtend,omitempty"` +} + +type RecordFilter struct { + Name string `json:"name,omitempty"` + Type []string `json:"type,omitempty"` + Content string `json:"content,omitempty"` + TTL int `json:"ttl,omitempty"` + Note string `json:"note,omitempty"` + Priority int `json:"priority,omitempty"` + Port int `json:"port,omitempty"` + Weight int `json:"weight,omitempty"` + Flags int `json:"flags,omitempty"` + Tag []string `json:"tag,omitempty"` +} diff --git a/providers/dns/internal/active24/provider.go b/providers/dns/internal/active24/provider.go new file mode 100644 index 000000000..ae79b8b17 --- /dev/null +++ b/providers/dns/internal/active24/provider.go @@ -0,0 +1,179 @@ +// Package active24 implements a DNS provider for solving the DNS-01 challenge using Active24. +package active24 + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/providers/dns/internal/active24/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIKey string + Secret string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Active24. +func NewDNSProviderConfig(config *Config, baseAPIDomain string) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(baseAPIDomain, config.APIKey, config.Secret) + if err != nil { + return nil, err + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return err + } + + serviceID, err := d.findServiceID(ctx, dns01.UnFqdn(authZone)) + if err != nil { + return fmt.Errorf("find service ID: %w", err) + } + + record := internal.Record{ + Type: "TXT", + Name: subDomain, + Content: info.Value, + TTL: d.config.TTL, + } + + err = d.client.CreateRecord(ctx, strconv.Itoa(serviceID), record) + if err != nil { + return fmt.Errorf("create record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("could not find zone for domain %q: %w", domain, err) + } + + serviceID, err := d.findServiceID(ctx, dns01.UnFqdn(authZone)) + if err != nil { + return fmt.Errorf("find service ID: %w", err) + } + + recordID, err := d.findRecordID(ctx, strconv.Itoa(serviceID), info) + if err != nil { + return fmt.Errorf("find record ID: %w", err) + } + + err = d.client.DeleteRecord(ctx, strconv.Itoa(serviceID), strconv.Itoa(recordID)) + if err != nil { + return fmt.Errorf("delete record %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) findServiceID(ctx context.Context, domain string) (int, error) { + services, err := d.client.GetServices(ctx) + if err != nil { + return 0, fmt.Errorf("get services: %w", err) + } + + for _, service := range services { + if service.ServiceName != "domain" { + continue + } + + if service.Name != domain { + continue + } + + return service.ID, nil + } + + return 0, fmt.Errorf("service not found for domain: %s", domain) +} + +func (d *DNSProvider) findRecordID(ctx context.Context, serviceID string, info dns01.ChallengeInfo) (int, error) { + // NOTE(ldez): Despite the API documentation, the filter doesn't seem to work. + filter := internal.RecordFilter{ + Name: dns01.UnFqdn(info.EffectiveFQDN), + Type: []string{"TXT"}, + Content: info.Value, + } + + records, err := d.client.GetRecords(ctx, serviceID, filter) + if err != nil { + return 0, fmt.Errorf("get records: %w", err) + } + + for _, record := range records { + if record.Type != "TXT" { + continue + } + + if record.Name != dns01.UnFqdn(info.EffectiveFQDN) { + continue + } + + if record.Content != info.Value { + continue + } + + return record.ID, nil + } + + return 0, errors.New("no record found") +} diff --git a/providers/dns/internal/active24/provider_test.go b/providers/dns/internal/active24/provider_test.go new file mode 100644 index 000000000..e2959fd6e --- /dev/null +++ b/providers/dns/internal/active24/provider_test.go @@ -0,0 +1,57 @@ +package active24 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + secret string + expected string + }{ + { + desc: "success", + apiKey: "user", + secret: "secret", + }, + { + desc: "missing API key", + apiKey: "", + secret: "secret", + expected: "credentials missing", + }, + { + desc: "missing secret", + apiKey: "user", + secret: "", + expected: "credentials missing", + }, + { + desc: "missing credentials", + expected: "credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := &Config{} + config.APIKey = test.apiKey + config.Secret = test.secret + + p, err := NewDNSProviderConfig(config, "example.com") + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} diff --git a/providers/dns/internal/clientdebug/.gitattributes b/providers/dns/internal/clientdebug/.gitattributes new file mode 100644 index 000000000..0ce5804f7 --- /dev/null +++ b/providers/dns/internal/clientdebug/.gitattributes @@ -0,0 +1 @@ +/testdata/** text eol=lf diff --git a/providers/dns/internal/clientdebug/client.go b/providers/dns/internal/clientdebug/client.go new file mode 100644 index 000000000..342577b93 --- /dev/null +++ b/providers/dns/internal/clientdebug/client.go @@ -0,0 +1,134 @@ +package clientdebug + +import ( + "fmt" + "io" + "net/http" + "net/http/httputil" + "os" + "regexp" + "strconv" + "strings" + + "github.com/go-acme/lego/v4/platform/config/env" +) + +const replacement = "***" + +type Option func(*DumpTransport) + +func WithEnvKeys(keys ...string) Option { + return func(d *DumpTransport) { + for _, key := range keys { + v := strings.TrimSpace(env.GetOrFile(key)) + if v == "" { + continue + } + + d.replacements = append(d.replacements, v, replacement) + } + } +} + +func WithValues(values ...string) Option { + return func(d *DumpTransport) { + for _, value := range values { + d.replacements = append(d.replacements, value, replacement) + } + } +} + +func WithHeaders(keys ...string) Option { + return func(d *DumpTransport) { + d.regexps = append(d.regexps, + regexp.MustCompile(fmt.Sprintf(`(?im)^(%s):.+$`, strings.Join(keys, "|")))) + } +} + +type DumpTransport struct { + rt http.RoundTripper + + replacements []string + replacer *strings.Replacer + + regexps []*regexp.Regexp + + writer io.Writer +} + +func NewDumpTransport(rt http.RoundTripper, opts ...Option) *DumpTransport { + if rt == nil { + rt = http.DefaultTransport + } + + d := &DumpTransport{ + rt: rt, + writer: os.Stdout, + } + + for _, opt := range opts { + opt(d) + } + + d.regexps = append(d.regexps, + regexp.MustCompile(`(?im)^(Authorization):.+$`), + regexp.MustCompile(`(?im)^(Token|X-Token):.+$`), + regexp.MustCompile(`(?im)^(Auth-Token|X-Auth-Token):.+$`), + regexp.MustCompile(`(?im)^(Api-Key|X-Api-Key|X-Api-Secret):.+$`), + ) + + if len(d.replacements) > 0 { + d.replacer = strings.NewReplacer(d.replacements...) + } + + return d +} + +func (d *DumpTransport) RoundTrip(h *http.Request) (*http.Response, error) { + data, _ := httputil.DumpRequestOut(h, true) + + _, _ = fmt.Fprintln(d.writer, "[HTTP Request]") + _, _ = fmt.Fprintln(d.writer, d.redact(data)) + + resp, err := d.rt.RoundTrip(h) + if err != nil { + return nil, err + } + + data, _ = httputil.DumpResponse(resp, true) + + _, _ = fmt.Fprintln(d.writer, "[HTTP Response]") + _, _ = fmt.Fprintln(d.writer, d.redact(data)) + + return resp, err +} + +func (d *DumpTransport) redact(content []byte) string { + data := string(content) + + for _, r := range d.regexps { + data = r.ReplaceAllString(data, "$1: "+replacement) + } + + if d.replacer == nil { + return data + } + + return d.replacer.Replace(data) +} + +// Wrap wraps an HTTP client Transport with the [DumpTransport]. +func Wrap(client *http.Client, opts ...Option) *http.Client { + val, found := os.LookupEnv("LEGO_DEBUG_DNS_API_HTTP_CLIENT") + if !found { + return client + } + + if ok, _ := strconv.ParseBool(val); !ok { + return client + } + + client.Transport = NewDumpTransport(client.Transport, opts...) + + return client +} diff --git a/providers/dns/internal/clientdebug/client_test.go b/providers/dns/internal/clientdebug/client_test.go new file mode 100644 index 000000000..3a0c4021a --- /dev/null +++ b/providers/dns/internal/clientdebug/client_test.go @@ -0,0 +1,174 @@ +package clientdebug + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "text/template" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWrap_redact_env_vars(t *testing.T) { + t.Setenv("LEGO_DEBUG_DNS_API_HTTP_CLIENT", "true") + + t.Setenv("MY_VAR_01", "env-aaaa-aaaa") + t.Setenv("MY_VAR_02", "query-aaaa-aaaa") + t.Setenv("MY_VAR_03", "path-aaaa-aaaa") + t.Setenv("MY_VAR_04", "request-body-aaaa-aaaa") + t.Setenv("MY_VAR_05", "request-header-aaaa-aaaa") + t.Setenv("MY_VAR_06", "response-body-aaaa-aaaa") + + buf := bytes.NewBufferString("") + + server, client, req := setupTest(t, buf, + WithEnvKeys("MY_VAR_01", "MY_VAR_02", "MY_VAR_03", "MY_VAR_04", "MY_VAR_05", "MY_VAR_06"), + ) + + now := time.Now() + + resp, err := client.Transport.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + assertDump(t, now, server, buf, "env_vars.txt") +} + +func TestWrap_redact_headers(t *testing.T) { + t.Setenv("LEGO_DEBUG_DNS_API_HTTP_CLIENT", "true") + + buf := bytes.NewBufferString("") + + server, client, req := setupTest(t, buf, + WithHeaders("Secret-Request-Header", "Super-Secret-Request-Header", "Secret-Response-Header"), + ) + + now := time.Now() + + resp, err := client.Transport.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + assertDump(t, now, server, buf, "headers.txt") +} + +func TestWrap_redact_values(t *testing.T) { + t.Setenv("LEGO_DEBUG_DNS_API_HTTP_CLIENT", "true") + + buf := bytes.NewBufferString("") + + server, client, req := setupTest(t, buf, + WithValues("query-aaaa-aaaa", "path-aaaa-aaaa", "request-body-aaaa-aaaa"), + ) + + now := time.Now() + + resp, err := client.Transport.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + assertDump(t, now, server, buf, "values.txt") +} + +func fakeRequest(t *testing.T, baseURL string) *http.Request { + t.Helper() + + endpoint, err := url.Parse(baseURL) + require.NoError(t, err) + + query := endpoint.Query() + query.Set("foo", "query-aaaa-aaaa") + endpoint.RawQuery = query.Encode() + + endpoint = endpoint.JoinPath("path-aaaa-aaaa") + + body := `{ + "foo": "request-body-aaaa-aaaa" +} +` + + req := httptest.NewRequest(http.MethodGet, endpoint.String(), bytes.NewBufferString(body)) + + req.Header.Set("X-Authorization", "not-redacted") + + req.Header.Set("Secret-Request-Header", "request-header-aaaa-aaaa") + req.Header.Set("Super-Secret-Request-Header", "env-aaaa-aaaa") + + req.Header.Set("Authorization", "header-aaaa-0000") + req.Header.Set("Token", "header-aaaa-0001") + req.Header.Set("X-Token", "header-aaaa-0002") + req.Header.Set("Auth-Token", "header-aaaa-0003") + req.Header.Set("X-Auth-Token", "header-aaaa-0004") + req.Header.Set("Api-Key", "header-aaaa-0006") + req.Header.Set("X-Api-Key", "header-aaaa-0007") + req.Header.Set("X-Api-Secret", "header-aaaa-0008") + + req.SetBasicAuth("user", "secret") + + return req +} + +func fakeResponse() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Secret-Response-Header", "response-header-aaaa-aaaa") + _, _ = w.Write([]byte(`{ + "bar": "response-body-aaaa-aaaa" +}`, + )) + } +} + +func withWriter(w io.Writer) Option { + return func(d *DumpTransport) { + if w != nil { + d.writer = w + } + } +} + +func setupTest(t *testing.T, buf io.Writer, opts ...Option) (*httptest.Server, *http.Client, *http.Request) { + t.Helper() + + server := httptest.NewServer(fakeResponse()) + + opts = append(opts, withWriter(buf)) + + client := Wrap(server.Client(), opts...) + + req := fakeRequest(t, server.URL) + + return server, client, req +} + +func assertDump(t *testing.T, now time.Time, server *httptest.Server, actual *bytes.Buffer, filename string) { + t.Helper() + + tmpl, err := template.New(filename).ParseFiles(filepath.Join("testdata", filename)) + require.NoError(t, err) + + expected := bytes.NewBufferString("") + + location, err := time.LoadLocation("GMT") + require.NoError(t, err) + + baseURL, err := url.Parse(server.URL) + require.NoError(t, err) + + err = tmpl.Execute(expected, map[string]string{ + "Host": baseURL.Host, + "Date": now.In(location).Format(time.RFC1123), + }) + require.NoError(t, err) + + assert.Equal(t, expected.String(), strings.ReplaceAll(actual.String(), "\r", "")) +} diff --git a/providers/dns/internal/clientdebug/testdata/env_vars.txt b/providers/dns/internal/clientdebug/testdata/env_vars.txt new file mode 100644 index 000000000..a2697850e --- /dev/null +++ b/providers/dns/internal/clientdebug/testdata/env_vars.txt @@ -0,0 +1,32 @@ +[HTTP Request] +GET /***?foo=*** HTTP/1.1 +Host: {{ .Host }} +User-Agent: Go-http-client/1.1 +Content-Length: 37 +Api-Key: *** +Auth-Token: *** +Authorization: *** +Secret-Request-Header: *** +Super-Secret-Request-Header: *** +Token: *** +X-Api-Key: *** +X-Api-Secret: *** +X-Auth-Token: *** +X-Authorization: not-redacted +X-Token: *** +Accept-Encoding: gzip + +{ + "foo": "***" +} + +[HTTP Response] +HTTP/1.1 200 OK +Content-Length: 37 +Content-Type: text/plain; charset=utf-8 +Date: {{ .Date }} +Secret-Response-Header: response-header-aaaa-aaaa + +{ + "bar": "***" +} diff --git a/providers/dns/internal/clientdebug/testdata/headers.txt b/providers/dns/internal/clientdebug/testdata/headers.txt new file mode 100644 index 000000000..fe803fb22 --- /dev/null +++ b/providers/dns/internal/clientdebug/testdata/headers.txt @@ -0,0 +1,32 @@ +[HTTP Request] +GET /path-aaaa-aaaa?foo=query-aaaa-aaaa HTTP/1.1 +Host: {{ .Host }} +User-Agent: Go-http-client/1.1 +Content-Length: 37 +Api-Key: *** +Auth-Token: *** +Authorization: *** +Secret-Request-Header: *** +Super-Secret-Request-Header: *** +Token: *** +X-Api-Key: *** +X-Api-Secret: *** +X-Auth-Token: *** +X-Authorization: not-redacted +X-Token: *** +Accept-Encoding: gzip + +{ + "foo": "request-body-aaaa-aaaa" +} + +[HTTP Response] +HTTP/1.1 200 OK +Content-Length: 37 +Content-Type: text/plain; charset=utf-8 +Date: {{ .Date }} +Secret-Response-Header: *** + +{ + "bar": "response-body-aaaa-aaaa" +} diff --git a/providers/dns/internal/clientdebug/testdata/values.txt b/providers/dns/internal/clientdebug/testdata/values.txt new file mode 100644 index 000000000..b40f51f14 --- /dev/null +++ b/providers/dns/internal/clientdebug/testdata/values.txt @@ -0,0 +1,32 @@ +[HTTP Request] +GET /***?foo=*** HTTP/1.1 +Host: {{ .Host }} +User-Agent: Go-http-client/1.1 +Content-Length: 37 +Api-Key: *** +Auth-Token: *** +Authorization: *** +Secret-Request-Header: request-header-aaaa-aaaa +Super-Secret-Request-Header: env-aaaa-aaaa +Token: *** +X-Api-Key: *** +X-Api-Secret: *** +X-Auth-Token: *** +X-Authorization: not-redacted +X-Token: *** +Accept-Encoding: gzip + +{ + "foo": "***" +} + +[HTTP Response] +HTTP/1.1 200 OK +Content-Length: 37 +Content-Type: text/plain; charset=utf-8 +Date: {{ .Date }} +Secret-Response-Header: response-header-aaaa-aaaa + +{ + "bar": "response-body-aaaa-aaaa" +} diff --git a/providers/dns/gcore/internal/client.go b/providers/dns/internal/gcore/internal/client.go similarity index 91% rename from providers/dns/gcore/internal/client.go rename to providers/dns/internal/gcore/internal/client.go index 085b4d6cb..f3ad4e461 100644 --- a/providers/dns/gcore/internal/client.go +++ b/providers/dns/internal/gcore/internal/client.go @@ -27,7 +27,7 @@ const txtRecordType = "TXT" type Client struct { token string - baseURL *url.URL + BaseURL *url.URL HTTPClient *http.Client } @@ -37,7 +37,7 @@ func NewClient(token string) *Client { return &Client{ token: token, - baseURL: baseURL, + BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } @@ -45,9 +45,10 @@ func NewClient(token string) *Client { // GetZone gets zone information. // https://api.gcore.com/docs/dns#tag/zones/operation/Zone func (c *Client) GetZone(ctx context.Context, name string) (Zone, error) { - endpoint := c.baseURL.JoinPath("v2", "zones", name) + endpoint := c.BaseURL.JoinPath("v2", "zones", name) zone := Zone{} + err := c.doRequest(ctx, http.MethodGet, endpoint, nil, &zone) if err != nil { return Zone{}, fmt.Errorf("get zone %s: %w", name, err) @@ -59,9 +60,10 @@ func (c *Client) GetZone(ctx context.Context, name string) (Zone, error) { // GetRRSet gets RRSet item. // https://api.gcore.com/docs/dns#tag/rrsets/operation/RRSet func (c *Client) GetRRSet(ctx context.Context, zone, name string) (RRSet, error) { - endpoint := c.baseURL.JoinPath("v2", "zones", zone, name, txtRecordType) + endpoint := c.BaseURL.JoinPath("v2", "zones", zone, name, txtRecordType) var result RRSet + err := c.doRequest(ctx, http.MethodGet, endpoint, nil, &result) if err != nil { return RRSet{}, fmt.Errorf("get txt records %s -> %s: %w", zone, name, err) @@ -73,7 +75,7 @@ func (c *Client) GetRRSet(ctx context.Context, zone, name string) (RRSet, error) // DeleteRRSet removes RRSet record. // https://api.gcore.com/docs/dns#tag/rrsets/operation/DeleteRRSet func (c *Client) DeleteRRSet(ctx context.Context, zone, name string) error { - endpoint := c.baseURL.JoinPath("v2", "zones", zone, name, txtRecordType) + endpoint := c.BaseURL.JoinPath("v2", "zones", zone, name, txtRecordType) err := c.doRequest(ctx, http.MethodDelete, endpoint, nil, nil) if err != nil { @@ -104,19 +106,19 @@ func (c *Client) AddRRSet(ctx context.Context, zone, recordName, value string, t // https://api.gcore.com/docs/dns#tag/rrsets/operation/CreateRRSet func (c *Client) createRRSet(ctx context.Context, zone, name string, record RRSet) error { - endpoint := c.baseURL.JoinPath("v2", "zones", zone, name, txtRecordType) + endpoint := c.BaseURL.JoinPath("v2", "zones", zone, name, txtRecordType) return c.doRequest(ctx, http.MethodPost, endpoint, record, nil) } // https://api.gcore.com/docs/dns#tag/rrsets/operation/UpdateRRSet func (c *Client) updateRRSet(ctx context.Context, zone, name string, record RRSet) error { - endpoint := c.baseURL.JoinPath("v2", "zones", zone, name, txtRecordType) + endpoint := c.BaseURL.JoinPath("v2", "zones", zone, name, txtRecordType) return c.doRequest(ctx, http.MethodPut, endpoint, record, nil) } -func (c *Client) doRequest(ctx context.Context, method string, endpoint *url.URL, bodyParams any, result any) error { +func (c *Client) doRequest(ctx context.Context, method string, endpoint *url.URL, bodyParams, result any) error { req, err := newJSONRequest(ctx, method, endpoint, bodyParams) if err != nil { return fmt.Errorf("new request: %w", err) @@ -180,6 +182,7 @@ func parseError(resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errAPI := APIError{StatusCode: resp.StatusCode} + err := json.Unmarshal(raw, &errAPI) if err != nil { errAPI.Message = string(raw) diff --git a/providers/dns/internal/gcore/internal/client_test.go b/providers/dns/internal/gcore/internal/client_test.go new file mode 100644 index 000000000..7d70c9308 --- /dev/null +++ b/providers/dns/internal/gcore/internal/client_test.go @@ -0,0 +1,165 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testToken = "test" + testRecordContent = "acme" + testTTL = 10 +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(testToken) + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders()) +} + +func TestClient_GetZone(t *testing.T) { + expected := Zone{Name: "example.com"} + + client := mockBuilder(). + Route("GET /v2/zones/example.com", + servermock.JSONEncode(expected)). + Build(t) + + zone, err := client.GetZone(t.Context(), "example.com") + require.NoError(t, err) + + assert.Equal(t, expected, zone) +} + +func TestClient_GetZone_error(t *testing.T) { + client := mockBuilder(). + Route("GET /v2/zones/example.com", + servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusInternalServerError)). + Build(t) + + _, err := client.GetZone(t.Context(), "example.com") + require.EqualError(t, err, "get zone example.com: 500: oops") +} + +func TestClient_GetRRSet(t *testing.T) { + expected := RRSet{ + TTL: testTTL, + Records: []Records{ + {Content: []string{testRecordContent}}, + }, + } + + client := mockBuilder(). + Route("GET /v2/zones/example.com/foo.example.com/TXT", + servermock.JSONEncode(expected)). + Build(t) + + rrSet, err := client.GetRRSet(t.Context(), "example.com", "foo.example.com") + require.NoError(t, err) + + assert.Equal(t, expected, rrSet) +} + +func TestClient_GetRRSet_error(t *testing.T) { + client := mockBuilder(). + Route("GET /v2/zones/example.com/foo.example.com/TXT", + servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusInternalServerError)). + Build(t) + + _, err := client.GetRRSet(t.Context(), "example.com", "foo.example.com") + require.EqualError(t, err, "get txt records example.com -> foo.example.com: 500: oops") +} + +func TestClient_DeleteRRSet(t *testing.T) { + client := mockBuilder(). + Route("DELETE /v2/zones/test.example.com/my.test.example.com/TXT", nil). + Build(t) + + err := client.DeleteRRSet(t.Context(), "test.example.com", "my.test.example.com.") + require.NoError(t, err) +} + +func TestClient_DeleteRRSet_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusInternalServerError)). + Build(t) + + err := client.DeleteRRSet(t.Context(), "test.example.com", "my.test.example.com.") + require.NoError(t, err) +} + +func TestClient_AddRRSet_add(t *testing.T) { + client := mockBuilder(). + // GetRRSet + Route("GET /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode(APIError{Message: "not found"}).WithStatusCode(http.StatusBadRequest)). + // createRRSet + Route("POST /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode([]Records{{Content: []string{testRecordContent}}}), + servermock.CheckRequestJSONBody(`{"ttl":10,"resource_records":[{"content":["acme"]}]}`)). + Build(t) + + err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL) + require.NoError(t, err) +} + +func TestClient_AddRRSet_add_error(t *testing.T) { + client := mockBuilder(). + // GetRRSet + Route("GET /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode(APIError{Message: "not found"}).WithStatusCode(http.StatusBadRequest)). + // createRRSet + Route("POST /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusBadRequest)). + Build(t) + + err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL) + require.EqualError(t, err, "400: oops") +} + +func TestClient_AddRRSet_update(t *testing.T) { + client := mockBuilder(). + // GetRRSet + Route("GET /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode(RRSet{ + TTL: testTTL, + Records: []Records{{Content: []string{"foo"}}}, + })). + // updateRRSet + Route("PUT /v2/zones/test.example.com/my.test.example.com/TXT", nil, + servermock.CheckRequestJSONBody(`{"ttl":10,"resource_records":[{"content":["acme"]},{"content":["foo"]}]}`)). + Build(t) + + err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL) + require.NoError(t, err) +} + +func TestClient_AddRRSet_update_error(t *testing.T) { + client := mockBuilder(). + // GetRRSet + Route("GET /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode(RRSet{ + TTL: testTTL, + Records: []Records{{Content: []string{"foo"}}}, + })). + // updateRRSet + Route("PUT /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusBadRequest)). + Build(t) + + err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL) + require.EqualError(t, err, "400: oops") +} diff --git a/providers/dns/gcore/internal/types.go b/providers/dns/internal/gcore/internal/types.go similarity index 100% rename from providers/dns/gcore/internal/types.go rename to providers/dns/internal/gcore/internal/types.go diff --git a/providers/dns/internal/gcore/provider.go b/providers/dns/internal/gcore/provider.go new file mode 100644 index 000000000..b2078eba5 --- /dev/null +++ b/providers/dns/internal/gcore/provider.go @@ -0,0 +1,126 @@ +// Package gcore implements a DNS provider for solving the DNS-01 challenge using G-Core. +package gcore + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/internal/gcore/internal" +) + +const ( + DefaultPropagationTimeout = 360 * time.Second + DefaultPollingInterval = 20 * time.Second +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config for DNSProvider. +type Config struct { + APIToken string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// DNSProvider an implementation of challenge.Provider contract. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProviderConfig return a DNSProvider instance configured for G-Core DNS API. +func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("the configuration of the DNS provider is nil") + } + + if config.APIToken == "" { + return nil, errors.New("incomplete credentials provided") + } + + client := internal.NewClient(config.APIToken) + + if baseURL != "" { + client.BaseURL, _ = url.Parse(baseURL) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, _, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx := context.Background() + + zone, err := d.guessZone(ctx, info.EffectiveFQDN) + if err != nil { + return err + } + + err = d.client.AddRRSet(ctx, zone, dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL) + if err != nil { + return fmt.Errorf("add txt record: %w", err) + } + + return nil +} + +// CleanUp removes the record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx := context.Background() + + zone, err := d.guessZone(ctx, info.EffectiveFQDN) + if err != nil { + return err + } + + err = d.client.DeleteRRSet(ctx, zone, dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("remove txt record: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) guessZone(ctx context.Context, fqdn string) (string, error) { + var lastErr error + + for zone := range dns01.UnFqdnDomainsSeq(fqdn) { + dnsZone, err := d.client.GetZone(ctx, zone) + if err != nil { + lastErr = err + continue + } + + return dnsZone.Name, nil + } + + return "", fmt.Errorf("zone %q not found: %w", fqdn, lastErr) +} diff --git a/providers/dns/internal/gcore/provider_test.go b/providers/dns/internal/gcore/provider_test.go new file mode 100644 index 000000000..f29dadff9 --- /dev/null +++ b/providers/dns/internal/gcore/provider_test.go @@ -0,0 +1,42 @@ +package gcore + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiToken string + expected string + }{ + { + desc: "success", + apiToken: "A", + }, + { + desc: "missing credentials", + expected: "incomplete credentials provided", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := &Config{} + config.APIToken = test.apiToken + + p, err := NewDNSProviderConfig(config, "") + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} diff --git a/providers/dns/internal/hostingde/client.go b/providers/dns/internal/hostingde/internal/client.go similarity index 73% rename from providers/dns/internal/hostingde/client.go rename to providers/dns/internal/hostingde/internal/client.go index 8416f202b..133c3479c 100644 --- a/providers/dns/internal/hostingde/client.go +++ b/providers/dns/internal/hostingde/internal/client.go @@ -1,4 +1,4 @@ -package hostingde +package internal import ( "bytes" @@ -10,14 +10,11 @@ import ( "net/url" "time" - "github.com/cenkalti/backoff/v4" + "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) -const ( - DefaultHostingdeBaseURL = "https://secure.hosting.de/api/dns/v1/json" - DefaultHTTPNetBaseURL = "https://partner.http.net/api/dns/v1/json" -) +const defaultBaseURL = "https://secure.hosting.de/api/dns/v1/json" // Client the API client for Hosting.de. type Client struct { @@ -29,7 +26,7 @@ type Client struct { // NewClient creates new Client. func NewClient(apiKey string) *Client { - baseURL, _ := url.Parse(DefaultHostingdeBaseURL) + baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, @@ -39,41 +36,31 @@ func NewClient(apiKey string) *Client { } // GetZone gets a zone. -func (c Client) GetZone(ctx context.Context, req ZoneConfigsFindRequest) (*ZoneConfig, error) { - var zoneConfig *ZoneConfig - - operation := func() error { +func (c *Client) GetZone(ctx context.Context, req ZoneConfigsFindRequest) (*ZoneConfig, error) { + operation := func() (*ZoneConfig, error) { response, err := c.ListZoneConfigs(ctx, req) if err != nil { - return backoff.Permanent(err) + return nil, backoff.Permanent(err) } if response.Data[0].Status != "active" { - return fmt.Errorf("unexpected status: %q", response.Data[0].Status) + return nil, fmt.Errorf("unexpected status: %q", response.Data[0].Status) } - zoneConfig = &response.Data[0] - - return nil + return &response.Data[0], nil } bo := backoff.NewExponentialBackOff() bo.InitialInterval = 3 * time.Second bo.MaxInterval = 10 * bo.InitialInterval - bo.MaxElapsedTime = 100 * bo.InitialInterval // retry in case the zone was edited recently and is not yet active - err := backoff.Retry(operation, bo) - if err != nil { - return nil, err - } - - return zoneConfig, nil + return backoff.Retry(ctx, operation, backoff.WithBackOff(bo), backoff.WithMaxElapsedTime(100*bo.InitialInterval)) } // ListZoneConfigs lists zone configuration. // https://www.hosting.de/api/?json#list-zoneconfigs -func (c Client) ListZoneConfigs(ctx context.Context, req ZoneConfigsFindRequest) (*ZoneResponse, error) { +func (c *Client) ListZoneConfigs(ctx context.Context, req ZoneConfigsFindRequest) (*ZoneResponse, error) { endpoint := c.BaseURL.JoinPath("zoneConfigsFind") req.AuthToken = c.apiKey @@ -98,7 +85,7 @@ func (c Client) ListZoneConfigs(ctx context.Context, req ZoneConfigsFindRequest) // UpdateZone updates a zone. // https://www.hosting.de/api/?json#updating-zones -func (c Client) UpdateZone(ctx context.Context, req ZoneUpdateRequest) (*Zone, error) { +func (c *Client) UpdateZone(ctx context.Context, req ZoneUpdateRequest) (*Zone, error) { endpoint := c.BaseURL.JoinPath("zoneUpdate") req.AuthToken = c.apiKey @@ -118,7 +105,7 @@ func (c Client) UpdateZone(ctx context.Context, req ZoneUpdateRequest) (*Zone, e return response.Response, nil } -func (c Client) post(ctx context.Context, endpoint *url.URL, request, result any) ([]byte, error) { +func (c *Client) post(ctx context.Context, endpoint *url.URL, request, result any) ([]byte, error) { body, err := json.Marshal(request) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) diff --git a/providers/dns/internal/hostingde/client_test.go b/providers/dns/internal/hostingde/internal/client_test.go similarity index 55% rename from providers/dns/internal/hostingde/client_test.go rename to providers/dns/internal/hostingde/internal/client_test.go index d538c8bc0..d55bbf690 100644 --- a/providers/dns/internal/hostingde/client_test.go +++ b/providers/dns/internal/hostingde/internal/client_test.go @@ -1,70 +1,30 @@ -package hostingde +package internal import ( - "bytes" - "context" "encoding/json" - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.HTTPClient = server.Client() client.BaseURL, _ = url.Parse(server.URL) - mux.HandleFunc(pattern, handler) - - return client -} - -func writeFixture(rw http.ResponseWriter, filename string) { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) + return client, nil } func TestClient_ListZoneConfigs(t *testing.T) { - client := setupTest(t, "/zoneConfigsFind", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - raw, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - body := string(bytes.TrimSpace(raw)) - if body != `{"authToken":"secret","filter":{"field":"zoneName","value":"example.com"},"limit":1,"page":1}` { - http.Error(rw, fmt.Sprintf("unexpected body: got %s", body), http.StatusBadRequest) - return - } - - writeFixture(rw, "zoneConfigsFind.json") - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /zoneConfigsFind", + servermock.ResponseFromFixture("zoneConfigsFind.json"), + servermock.CheckRequestJSONBodyFromFixture("zoneConfigsFind-request.json")). + Build(t) zonesFind := ZoneConfigsFindRequest{ Filter: Filter{Field: "zoneName", Value: "example.com"}, @@ -72,7 +32,7 @@ func TestClient_ListZoneConfigs(t *testing.T) { Page: 1, } - zoneResponse, err := client.ListZoneConfigs(context.Background(), zonesFind) + zoneResponse, err := client.ListZoneConfigs(t.Context(), zonesFind) require.NoError(t, err) expected := &ZoneResponse{ @@ -109,14 +69,10 @@ func TestClient_ListZoneConfigs(t *testing.T) { } func TestClient_ListZoneConfigs_error(t *testing.T) { - client := setupTest(t, "/zoneConfigsFind", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - writeFixture(rw, "zoneConfigsFind_error.json") - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /zoneConfigsFind", + servermock.ResponseFromFixture("zoneConfigsFind_error.json")). + Build(t) zonesFind := ZoneConfigsFindRequest{ Filter: Filter{Field: "zoneName", Value: "example.com"}, @@ -124,31 +80,16 @@ func TestClient_ListZoneConfigs_error(t *testing.T) { Page: 1, } - _, err := client.ListZoneConfigs(context.Background(), zonesFind) + _, err := client.ListZoneConfigs(t.Context(), zonesFind) require.Error(t, err) } func TestClient_UpdateZone(t *testing.T) { - client := setupTest(t, "/zoneUpdate", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - raw, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - body := string(bytes.TrimSpace(raw)) - if body != `{"authToken":"secret","zoneConfig":{"id":"123","accountId":"456","status":"s","name":"n","nameUnicode":"u","masterIp":"m","type":"t","emailAddress":"e","zoneTransferWhitelist":["a","b"],"lastChangeDate":"l","dnsServerGroupId":"g","dnsSecMode":"m","soaValues":{"refresh":1,"retry":2,"expire":3,"ttl":4,"negativeTtl":5}},"recordsToAdd":null,"recordsToDelete":[{"name":"_acme-challenge.example.com","type":"TXT","content":"\"txt\""}]}` { - http.Error(rw, fmt.Sprintf("unexpected body: got %s", body), http.StatusBadRequest) - return - } - - writeFixture(rw, "zoneUpdate.json") - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /zoneUpdate", + servermock.ResponseFromFixture("zoneUpdate.json"), + servermock.CheckRequestJSONBodyFromFixture("zoneUpdate-request.json")). + Build(t) request := ZoneUpdateRequest{ ZoneConfig: ZoneConfig{ @@ -179,7 +120,7 @@ func TestClient_UpdateZone(t *testing.T) { }}, } - response, err := client.UpdateZone(context.Background(), request) + response, err := client.UpdateZone(t.Context(), request) require.NoError(t, err) expected := &Zone{ @@ -221,14 +162,10 @@ func TestClient_UpdateZone(t *testing.T) { } func TestClient_UpdateZone_error(t *testing.T) { - client := setupTest(t, "/zoneUpdate", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - writeFixture(rw, "zoneUpdate_error.json") - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /zoneUpdate", + servermock.ResponseFromFixture("zoneUpdate_error.json")). + Build(t) request := ZoneUpdateRequest{ ZoneConfig: ZoneConfig{ @@ -259,6 +196,6 @@ func TestClient_UpdateZone_error(t *testing.T) { }}, } - _, err := client.UpdateZone(context.Background(), request) + _, err := client.UpdateZone(t.Context(), request) require.Error(t, err) } diff --git a/providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind-request.json b/providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind-request.json new file mode 100644 index 000000000..eb552d9eb --- /dev/null +++ b/providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind-request.json @@ -0,0 +1,9 @@ +{ + "authToken": "secret", + "filter": { + "field": "zoneName", + "value": "example.com" + }, + "limit": 1, + "page": 1 +} diff --git a/providers/dns/internal/hostingde/fixtures/zoneConfigsFind.json b/providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind.json similarity index 100% rename from providers/dns/internal/hostingde/fixtures/zoneConfigsFind.json rename to providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind.json diff --git a/providers/dns/internal/hostingde/fixtures/zoneConfigsFind_error.json b/providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind_error.json similarity index 100% rename from providers/dns/internal/hostingde/fixtures/zoneConfigsFind_error.json rename to providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind_error.json diff --git a/providers/dns/internal/hostingde/internal/fixtures/zoneUpdate-request.json b/providers/dns/internal/hostingde/internal/fixtures/zoneUpdate-request.json new file mode 100644 index 000000000..38b1be50d --- /dev/null +++ b/providers/dns/internal/hostingde/internal/fixtures/zoneUpdate-request.json @@ -0,0 +1,35 @@ +{ + "authToken": "secret", + "zoneConfig": { + "id": "123", + "accountId": "456", + "status": "s", + "name": "n", + "nameUnicode": "u", + "masterIp": "m", + "type": "t", + "emailAddress": "e", + "zoneTransferWhitelist": [ + "a", + "b" + ], + "lastChangeDate": "l", + "dnsServerGroupId": "g", + "dnsSecMode": "m", + "soaValues": { + "refresh": 1, + "retry": 2, + "expire": 3, + "ttl": 4, + "negativeTtl": 5 + } + }, + "recordsToAdd": null, + "recordsToDelete": [ + { + "name": "_acme-challenge.example.com", + "type": "TXT", + "content": "\"txt\"" + } + ] +} diff --git a/providers/dns/internal/hostingde/fixtures/zoneUpdate.json b/providers/dns/internal/hostingde/internal/fixtures/zoneUpdate.json similarity index 100% rename from providers/dns/internal/hostingde/fixtures/zoneUpdate.json rename to providers/dns/internal/hostingde/internal/fixtures/zoneUpdate.json diff --git a/providers/dns/internal/hostingde/fixtures/zoneUpdate_error.json b/providers/dns/internal/hostingde/internal/fixtures/zoneUpdate_error.json similarity index 100% rename from providers/dns/internal/hostingde/fixtures/zoneUpdate_error.json rename to providers/dns/internal/hostingde/internal/fixtures/zoneUpdate_error.json diff --git a/providers/dns/internal/hostingde/types.go b/providers/dns/internal/hostingde/internal/types.go similarity index 98% rename from providers/dns/internal/hostingde/types.go rename to providers/dns/internal/hostingde/internal/types.go index 4f3347190..330eab27d 100644 --- a/providers/dns/internal/hostingde/types.go +++ b/providers/dns/internal/hostingde/internal/types.go @@ -1,4 +1,4 @@ -package hostingde +package internal import "encoding/json" @@ -88,7 +88,8 @@ type Zone struct { // https://www.hosting.de/api/?json#updating-zones type ZoneUpdateRequest struct { BaseRequest - ZoneConfig `json:"zoneConfig"` + ZoneConfig `json:"zoneConfig"` + RecordsToAdd []DNSRecord `json:"recordsToAdd"` RecordsToDelete []DNSRecord `json:"recordsToDelete"` } @@ -97,6 +98,7 @@ type ZoneUpdateRequest struct { // https://www.hosting.de/api/?json#list-zoneconfigs type ZoneConfigsFindRequest struct { BaseRequest + Filter Filter `json:"filter"` Limit int `json:"limit"` Page int `json:"page"` diff --git a/providers/dns/internal/hostingde/provider.go b/providers/dns/internal/hostingde/provider.go new file mode 100644 index 000000000..b5277f042 --- /dev/null +++ b/providers/dns/internal/hostingde/provider.go @@ -0,0 +1,196 @@ +// Package hostingde implements a DNS provider for solving the DNS-01 challenge using hosting.de. +package hostingde + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/internal/hostingde/internal" +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIKey string + ZoneName string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + recordIDs map[string]string + recordIDsMu sync.Mutex +} + +// NewDNSProviderConfig return a DNSProvider instance configured for hosting.de. +func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("the configuration of the DNS provider is nil") + } + + if config.APIKey == "" { + return nil, errors.New("API key missing") + } + + client := internal.NewClient(config.APIKey) + + if baseURL != "" { + client.BaseURL, _ = url.Parse(baseURL) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]string), + }, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + zoneName, err := d.getZoneName(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("could not find zone for domain %q: %w", domain, err) + } + + ctx := context.Background() + + // get the ZoneConfig for that domain + zonesFind := internal.ZoneConfigsFindRequest{ + Filter: internal.Filter{Field: "zoneName", Value: zoneName}, + Limit: 1, + Page: 1, + } + + zoneConfig, err := d.client.GetZone(ctx, zonesFind) + if err != nil { + return err + } + + zoneConfig.Name = zoneName + + rec := []internal.DNSRecord{{ + Type: "TXT", + Name: dns01.UnFqdn(info.EffectiveFQDN), + Content: info.Value, + TTL: d.config.TTL, + }} + + req := internal.ZoneUpdateRequest{ + ZoneConfig: *zoneConfig, + RecordsToAdd: rec, + } + + response, err := d.client.UpdateZone(ctx, req) + if err != nil { + return err + } + + for _, record := range response.Records { + if record.Name == dns01.UnFqdn(info.EffectiveFQDN) && record.Content == fmt.Sprintf(`%q`, info.Value) { + d.recordIDsMu.Lock() + d.recordIDs[info.EffectiveFQDN] = record.ID + d.recordIDsMu.Unlock() + } + } + + if d.recordIDs[info.EffectiveFQDN] == "" { + return fmt.Errorf("error getting ID of just created record, for domain %s", domain) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + zoneName, err := d.getZoneName(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("could not find zone for domain %q: %w", domain, err) + } + + ctx := context.Background() + + // get the ZoneConfig for that domain + zonesFind := internal.ZoneConfigsFindRequest{ + Filter: internal.Filter{Field: "zoneName", Value: zoneName}, + Limit: 1, + Page: 1, + } + + zoneConfig, err := d.client.GetZone(ctx, zonesFind) + if err != nil { + return err + } + + zoneConfig.Name = zoneName + + rec := []internal.DNSRecord{{ + Type: "TXT", + Name: dns01.UnFqdn(info.EffectiveFQDN), + Content: `"` + info.Value + `"`, + }} + + req := internal.ZoneUpdateRequest{ + ZoneConfig: *zoneConfig, + RecordsToDelete: rec, + } + + _, err = d.client.UpdateZone(ctx, req) + if err != nil { + return err + } + + // Delete record ID from map + d.recordIDsMu.Lock() + delete(d.recordIDs, info.EffectiveFQDN) + d.recordIDsMu.Unlock() + + return nil +} + +func (d *DNSProvider) getZoneName(fqdn string) (string, error) { + if d.config.ZoneName != "" { + return d.config.ZoneName, nil + } + + zoneName, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err) + } + + if zoneName == "" { + return "", errors.New("empty zone name") + } + + return dns01.UnFqdn(zoneName), nil +} diff --git a/providers/dns/internal/hostingde/provider_test.go b/providers/dns/internal/hostingde/provider_test.go new file mode 100644 index 000000000..3cdabf702 --- /dev/null +++ b/providers/dns/internal/hostingde/provider_test.go @@ -0,0 +1,50 @@ +package hostingde + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + zoneName string + expected string + }{ + { + desc: "success", + apiKey: "123", + zoneName: "example.org", + }, + { + desc: "missing credentials", + expected: "API key missing", + }, + { + desc: "missing api key", + zoneName: "456", + expected: "API key missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := &Config{} + config.APIKey = test.apiKey + config.ZoneName = test.zoneName + + p, err := NewDNSProviderConfig(config, "") + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.recordIDs) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} diff --git a/providers/dns/ionos/internal/client.go b/providers/dns/internal/ionos/internal/client.go similarity index 97% rename from providers/dns/ionos/internal/client.go rename to providers/dns/internal/ionos/internal/client.go index 8b37d5f1c..2a556a49b 100644 --- a/providers/dns/ionos/internal/client.go +++ b/providers/dns/internal/ionos/internal/client.go @@ -14,9 +14,11 @@ import ( querystring "github.com/google/go-querystring/query" ) -// defaultBaseURL represents the API endpoint to call. const defaultBaseURL = "https://api.hosting.ionos.com/dns" +// APIKeyHeader API key header. +const APIKeyHeader = "X-Api-Key" + // Client Ionos API client. type Client struct { apiKey string @@ -49,6 +51,7 @@ func (c *Client) ListZones(ctx context.Context) ([]Zone, error) { } var zones []Zone + err = c.do(req, &zones) if err != nil { return nil, fmt.Errorf("failed to call API: %w", err) @@ -93,6 +96,7 @@ func (c *Client) GetRecords(ctx context.Context, zoneID string, filter *RecordsF } var zone CustomerZone + err = c.do(req, &zone) if err != nil { return nil, fmt.Errorf("failed to call API: %w", err) @@ -119,7 +123,7 @@ func (c *Client) RemoveRecord(ctx context.Context, zoneID, recordID string) erro } func (c *Client) do(req *http.Request, result any) error { - req.Header.Set("X-API-Key", c.apiKey) + req.Header.Set(APIKeyHeader, c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { @@ -177,6 +181,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errClient := &ClientError{StatusCode: resp.StatusCode} + err := json.Unmarshal(raw, &errClient.errors) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/internal/ionos/internal/client_test.go b/providers/dns/internal/ionos/internal/client_test.go new file mode 100644 index 000000000..008d153bc --- /dev/null +++ b/providers/dns/internal/ionos/internal/client_test.go @@ -0,0 +1,162 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + servermock.CheckHeader().With(APIKeyHeader, "secret")) +} + +func TestClient_ListZones(t *testing.T) { + client := mockBuilder(). + Route("GET /v1/zones", + servermock.ResponseFromFixture("list_zones.json")). + Build(t) + + zones, err := client.ListZones(t.Context()) + require.NoError(t, err) + + expected := []Zone{{ + ID: "11af3414-ebba-11e9-8df5-66fbe8a334b4", + Name: "test.com", + Type: "NATIVE", + }} + + assert.Equal(t, expected, zones) +} + +func TestClient_ListZones_error(t *testing.T) { + client := mockBuilder(). + Route("GET /v1/zones", + servermock.ResponseFromFixture("list_zones_error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + zones, err := client.ListZones(t.Context()) + require.Error(t, err) + + assert.Nil(t, zones) + + var cErr *ClientError + assert.ErrorAs(t, err, &cErr) + assert.Equal(t, http.StatusUnauthorized, cErr.StatusCode) +} + +func TestClient_GetRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /v1/zones/azone01", + servermock.ResponseFromFixture("get_records.json")). + Build(t) + + records, err := client.GetRecords(t.Context(), "azone01", nil) + require.NoError(t, err) + + expected := []Record{{ + ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4", + Name: "string", + Content: "string", + Type: "A", + }} + + assert.Equal(t, expected, records) +} + +func TestClient_GetRecords_error(t *testing.T) { + client := mockBuilder(). + Route("GET /v1/zones/azone01", + servermock.ResponseFromFixture("get_records_error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + records, err := client.GetRecords(t.Context(), "azone01", nil) + require.Error(t, err) + + assert.Nil(t, records) + + var cErr *ClientError + assert.ErrorAs(t, err, &cErr) + assert.Equal(t, http.StatusUnauthorized, cErr.StatusCode) +} + +func TestClient_RemoveRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /v1/zones/azone01/records/arecord01", nil). + Build(t) + + err := client.RemoveRecord(t.Context(), "azone01", "arecord01") + require.NoError(t, err) +} + +func TestClient_RemoveRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /v1/zones/azone01/records/arecord01", + servermock.ResponseFromFixture("remove_record_error.json"). + WithStatusCode(http.StatusInternalServerError)). + Build(t) + + err := client.RemoveRecord(t.Context(), "azone01", "arecord01") + require.Error(t, err) + + var cErr *ClientError + assert.ErrorAs(t, err, &cErr) + assert.Equal(t, http.StatusInternalServerError, cErr.StatusCode) +} + +func TestClient_ReplaceRecords(t *testing.T) { + client := mockBuilder(). + Route("PATCH /v1/zones/azone01", nil). + Build(t) + + records := []Record{{ + ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4", + Name: "string", + Content: "string", + Type: "A", + }} + + err := client.ReplaceRecords(t.Context(), "azone01", records) + require.NoError(t, err) +} + +func TestClient_ReplaceRecords_error(t *testing.T) { + client := mockBuilder(). + Route("PATCH /v1/zones/azone01", + servermock.ResponseFromFixture("replace_records_error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + records := []Record{{ + ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4", + Name: "string", + Content: "string", + Type: "A", + }} + + err := client.ReplaceRecords(t.Context(), "azone01", records) + require.Error(t, err) + + var cErr *ClientError + assert.ErrorAs(t, err, &cErr) + assert.Equal(t, http.StatusBadRequest, cErr.StatusCode) +} diff --git a/providers/dns/ionos/internal/fixtures/get_records.json b/providers/dns/internal/ionos/internal/fixtures/get_records.json similarity index 100% rename from providers/dns/ionos/internal/fixtures/get_records.json rename to providers/dns/internal/ionos/internal/fixtures/get_records.json diff --git a/providers/dns/ionos/internal/fixtures/get_records_error.json b/providers/dns/internal/ionos/internal/fixtures/get_records_error.json similarity index 100% rename from providers/dns/ionos/internal/fixtures/get_records_error.json rename to providers/dns/internal/ionos/internal/fixtures/get_records_error.json diff --git a/providers/dns/ionos/internal/fixtures/list_zones.json b/providers/dns/internal/ionos/internal/fixtures/list_zones.json similarity index 100% rename from providers/dns/ionos/internal/fixtures/list_zones.json rename to providers/dns/internal/ionos/internal/fixtures/list_zones.json diff --git a/providers/dns/ionos/internal/fixtures/list_zones_error.json b/providers/dns/internal/ionos/internal/fixtures/list_zones_error.json similarity index 100% rename from providers/dns/ionos/internal/fixtures/list_zones_error.json rename to providers/dns/internal/ionos/internal/fixtures/list_zones_error.json diff --git a/providers/dns/ionos/internal/fixtures/remove_record_error.json b/providers/dns/internal/ionos/internal/fixtures/remove_record_error.json similarity index 100% rename from providers/dns/ionos/internal/fixtures/remove_record_error.json rename to providers/dns/internal/ionos/internal/fixtures/remove_record_error.json diff --git a/providers/dns/ionos/internal/fixtures/replace_records_error.json b/providers/dns/internal/ionos/internal/fixtures/replace_records_error.json similarity index 100% rename from providers/dns/ionos/internal/fixtures/replace_records_error.json rename to providers/dns/internal/ionos/internal/fixtures/replace_records_error.json diff --git a/providers/dns/ionos/internal/types.go b/providers/dns/internal/ionos/internal/types.go similarity index 91% rename from providers/dns/ionos/internal/types.go rename to providers/dns/internal/ionos/internal/types.go index 3b7acbec2..35bfe0966 100644 --- a/providers/dns/ionos/internal/types.go +++ b/providers/dns/internal/ionos/internal/types.go @@ -3,6 +3,7 @@ package internal import ( "fmt" "strconv" + "strings" ) // ClientError a detailed error. @@ -13,21 +14,23 @@ type ClientError struct { } func (f ClientError) Error() string { - msg := strconv.Itoa(f.StatusCode) + ": " + var msg strings.Builder + + msg.WriteString(strconv.Itoa(f.StatusCode) + ": ") if f.message != "" { - msg += f.message + ": " + msg.WriteString(f.message + ": ") } for i, e := range f.errors { if i != 0 { - msg += ", " + msg.WriteString(", ") } - msg += e.Error() + msg.WriteString(e.Error()) } - return msg + return msg.String() } func (f ClientError) Unwrap() error { diff --git a/providers/dns/internal/ionos/provider.go b/providers/dns/internal/ionos/provider.go new file mode 100644 index 000000000..a7d145840 --- /dev/null +++ b/providers/dns/internal/ionos/provider.go @@ -0,0 +1,173 @@ +package ionos + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + ionos "github.com/go-acme/lego/v4/providers/dns/internal/ionos/internal" +) + +const MinTTL = 300 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *ionos.Client +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Ionos. +func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("the configuration of the DNS provider is nil") + } + + if config.APIKey == "" { + return nil, errors.New("credentials missing") + } + + if config.TTL < MinTTL { + return nil, fmt.Errorf("invalid TTL, TTL (%d) must be greater than %d", config.TTL, MinTTL) + } + + client, err := ionos.NewClient(config.APIKey) + if err != nil { + return nil, err + } + + if baseURL != "" { + client.BaseURL, _ = url.Parse(baseURL) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{config: config, client: client}, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, _, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx := context.Background() + + zones, err := d.client.ListZones(ctx) + if err != nil { + return fmt.Errorf("failed to get zones: %w", err) + } + + name := dns01.UnFqdn(info.EffectiveFQDN) + + zone := findZone(zones, name) + if zone == nil { + return errors.New("no matching zone found for domain") + } + + filter := &ionos.RecordsFilter{ + Suffix: name, + RecordType: "TXT", + } + + records, err := d.client.GetRecords(ctx, zone.ID, filter) + if err != nil { + return fmt.Errorf("failed to get records (zone=%s): %w", zone.ID, err) + } + + records = append(records, ionos.Record{ + Name: name, + Content: info.Value, + TTL: d.config.TTL, + Type: "TXT", + }) + + err = d.client.ReplaceRecords(ctx, zone.ID, records) + if err != nil { + return fmt.Errorf("failed to create/update records (zone=%s): %w", zone.ID, err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx := context.Background() + + zones, err := d.client.ListZones(ctx) + if err != nil { + return fmt.Errorf("failed to get zones: %w", err) + } + + name := dns01.UnFqdn(info.EffectiveFQDN) + + zone := findZone(zones, name) + if zone == nil { + return errors.New("no matching zone found for domain") + } + + filter := &ionos.RecordsFilter{ + Suffix: name, + RecordType: "TXT", + } + + records, err := d.client.GetRecords(ctx, zone.ID, filter) + if err != nil { + return fmt.Errorf("failed to get records (zone=%s): %w", zone.ID, err) + } + + for _, record := range records { + if record.Name == name && record.Content == strconv.Quote(info.Value) { + err = d.client.RemoveRecord(ctx, zone.ID, record.ID) + if err != nil { + return fmt.Errorf("failed to remove record (zone=%s, record=%s): %w", zone.ID, record.ID, err) + } + + return nil + } + } + + return fmt.Errorf("failed to remove record, record not found (zone=%s, domain=%s, fqdn=%s, value=%s)", zone.ID, domain, info.EffectiveFQDN, info.Value) +} + +func findZone(zones []ionos.Zone, domain string) *ionos.Zone { + var result *ionos.Zone + + for _, zone := range zones { + if zone.Name != "" && strings.HasSuffix(domain, zone.Name) { + if result == nil || len(zone.Name) > len(result.Name) { + result = &zone + } + } + } + + return result +} diff --git a/providers/dns/internal/ionos/provider_test.go b/providers/dns/internal/ionos/provider_test.go new file mode 100644 index 000000000..6b4df5cc7 --- /dev/null +++ b/providers/dns/internal/ionos/provider_test.go @@ -0,0 +1,52 @@ +package ionos + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + tll int + expected string + }{ + { + desc: "success", + apiKey: "123", + tll: MinTTL, + }, + { + desc: "missing credentials", + tll: MinTTL, + expected: "credentials missing", + }, + { + desc: "invalid TTL", + apiKey: "123", + tll: 30, + expected: "invalid TTL, TTL (30) must be greater than 300", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := &Config{} + config.APIKey = test.apiKey + config.TTL = test.tll + + p, err := NewDNSProviderConfig(config, "") + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} diff --git a/providers/dns/internal/ptr/types.go b/providers/dns/internal/ptr/types.go new file mode 100644 index 000000000..b0c7974e0 --- /dev/null +++ b/providers/dns/internal/ptr/types.go @@ -0,0 +1,12 @@ +package ptr + +func Deref[T any](v *T) T { + if v == nil { + var zero T + return zero + } + + return *v +} + +func Pointer[T any](v T) *T { return &v } diff --git a/providers/dns/internal/rimuhosting/client_test.go b/providers/dns/internal/rimuhosting/client_test.go deleted file mode 100644 index ecd55b0b5..000000000 --- a/providers/dns/internal/rimuhosting/client_test.go +++ /dev/null @@ -1,317 +0,0 @@ -package rimuhosting - -import ( - "context" - "encoding/xml" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("apikeyvaluehere") - client.BaseURL = server.URL - client.HTTPClient = server.Client() - - return client, mux -} - -func TestClient_FindTXTRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - query := req.URL.Query() - - var fixture string - switch query.Get("name") { - case "example.com": - fixture = "./fixtures/find_records.xml" - case "**.example.com": - fixture = "./fixtures/find_records_pattern.xml" - default: - fixture = "./fixtures/find_records_empty.xml" - } - - err := writeResponse(rw, fixture) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - testCases := []struct { - desc string - domain string - expected []Record - }{ - { - desc: "simple", - domain: "example.com", - expected: []Record{ - { - Name: "example.org", - Type: "TXT", - Content: "txttxtx", - TTL: "3600 seconds", - Priority: "0", - }, - }, - }, - { - desc: "pattern", - domain: "**.example.com", - expected: []Record{ - { - Name: "_test.example.org", - Type: "TXT", - Content: "txttxtx", - TTL: "3600 seconds", - Priority: "0", - }, - { - Name: "example.org", - Type: "TXT", - Content: "txttxtx", - TTL: "3600 seconds", - Priority: "0", - }, - }, - }, - { - desc: "empty", - domain: "empty.com", - expected: nil, - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - records, err := client.FindTXTRecords(context.Background(), test.domain) - require.NoError(t, err) - - assert.Equal(t, test.expected, records) - }) - } -} - -func TestClient_DoActions(t *testing.T) { - type expected struct { - Query string - Resp *DNSAPIResult - Error string - } - - testCases := []struct { - desc string - actions []ActionParameter - fixture string - expected expected - }{ - { - desc: "SET error", - actions: []ActionParameter{ - NewAddRecordAction("example.com", "txttxtx", 0), - }, - fixture: "./fixtures/add_record_error.xml", - expected: expected{ - Query: "action=SET&api_key=apikeyvaluehere&name=example.com&type=TXT&value=txttxtx", - Error: "ERROR: No zone found for example.com", - }, - }, - { - desc: "SET simple", - actions: []ActionParameter{ - NewAddRecordAction("example.org", "txttxtx", 0), - }, - fixture: "./fixtures/add_record.xml", - expected: expected{ - Query: "action=SET&api_key=apikeyvaluehere&name=example.org&type=TXT&value=txttxtx", - Resp: &DNSAPIResult{ - XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, - IsOk: "OK:", - ResultCounts: ResultCounts{Added: "1", Changed: "0", Unchanged: "0", Deleted: "0"}, - Actions: Actions{ - Action: Action{ - Action: "SET", - Host: "example.org", - Type: "TXT", - Records: []Record{{ - Name: "example.org", - Type: "TXT", - Content: "txttxtx", - TTL: "3600 seconds", - Priority: "0", - }}, - }, - }, - }, - }, - }, - { - desc: "SET multiple values", - actions: []ActionParameter{ - NewAddRecordAction("example.org", "txttxtx", 0), - NewAddRecordAction("example.org", "sample", 0), - }, - fixture: "./fixtures/add_record_same_domain.xml", - expected: expected{ - Query: "action[0]=SET&action[1]=SET&api_key=apikeyvaluehere&name[0]=example.org&name[1]=example.org&ttl[0]=0&ttl[1]=0&type[0]=TXT&type[1]=TXT&value[0]=txttxtx&value[1]=sample", - Resp: &DNSAPIResult{ - XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, - IsOk: "OK:", - ResultCounts: ResultCounts{Added: "2", Changed: "0", Unchanged: "0", Deleted: "0"}, - Actions: Actions{ - Action: Action{ - Action: "SET", - Host: "example.org", - Type: "TXT", - Records: []Record{ - { - Name: "example.org", - Type: "TXT", - Content: "txttxtx", - TTL: "0 seconds", - Priority: "0", - }, - { - Name: "example.org", - Type: "TXT", - Content: "sample", - TTL: "0 seconds", - Priority: "0", - }, - }, - }, - }, - }, - }, - }, - { - desc: "DELETE error", - actions: []ActionParameter{ - NewDeleteRecordAction("example.com", "txttxtx"), - }, - fixture: "./fixtures/delete_record_error.xml", - expected: expected{ - Query: "action=DELETE&api_key=apikeyvaluehere&name=example.com&type=TXT&value=txttxtx", - Error: "ERROR: No zone found for example.com", - }, - }, - { - desc: "DELETE nothing", - actions: []ActionParameter{ - NewDeleteRecordAction("example.org", "nothing"), - }, - fixture: "./fixtures/delete_record_nothing.xml", - expected: expected{ - Query: "action=DELETE&api_key=apikeyvaluehere&name=example.org&type=TXT&value=nothing", - Resp: &DNSAPIResult{ - XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, - IsOk: "OK:", - ResultCounts: ResultCounts{Added: "0", Changed: "0", Unchanged: "0", Deleted: "0"}, - Actions: Actions{ - Action: Action{ - Action: "DELETE", - Host: "example.org", - Type: "TXT", - Records: nil, - }, - }, - }, - }, - }, - { - desc: "DELETE simple", - actions: []ActionParameter{ - NewDeleteRecordAction("example.org", "txttxtx"), - }, - fixture: "./fixtures/delete_record.xml", - expected: expected{ - Query: "action=DELETE&api_key=apikeyvaluehere&name=example.org&type=TXT&value=txttxtx", - Resp: &DNSAPIResult{ - XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, - IsOk: "OK:", - ResultCounts: ResultCounts{Added: "0", Changed: "0", Unchanged: "0", Deleted: "1"}, - Actions: Actions{ - Action: Action{ - Action: "DELETE", - Host: "example.org", - Type: "TXT", - Records: []Record{{ - Name: "example.org", - Type: "TXT", - Content: "txttxtx", - TTL: "3600 seconds", - Priority: "0", - }}, - }, - }, - }, - }, - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - query, err := url.QueryUnescape(req.URL.RawQuery) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if test.expected.Query != query { - http.Error(rw, fmt.Sprintf("invalid query: %s", query), http.StatusBadRequest) - return - } - - if test.expected.Error != "" { - rw.WriteHeader(http.StatusInternalServerError) - } - - err = writeResponse(rw, test.fixture) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - resp, err := client.DoActions(context.Background(), test.actions...) - if test.expected.Error != "" { - require.EqualError(t, err, test.expected.Error) - return - } - - require.NoError(t, err) - - assert.Equal(t, test.expected.Resp, resp) - }) - } -} - -func writeResponse(rw io.Writer, filename string) error { - file, err := os.Open(filename) - if err != nil { - return err - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - return err -} diff --git a/providers/dns/internal/rimuhosting/client.go b/providers/dns/internal/rimuhosting/internal/client.go similarity index 86% rename from providers/dns/internal/rimuhosting/client.go rename to providers/dns/internal/rimuhosting/internal/client.go index 4976f3781..5bf7393e7 100644 --- a/providers/dns/internal/rimuhosting/client.go +++ b/providers/dns/internal/rimuhosting/internal/client.go @@ -1,4 +1,4 @@ -package rimuhosting +package internal import ( "context" @@ -15,11 +15,7 @@ import ( querystring "github.com/google/go-querystring/query" ) -// Base URL for the RimuHosting DNS services. -const ( - DefaultZonomiBaseURL = "https://zonomi.com/app/dns/dyndns.jsp" - DefaultRimuHostingBaseURL = "https://rimuhosting.com/dns/dyndns.jsp" -) +const defaultBaseURL = "https://rimuhosting.com/dns/dyndns.jsp" // Action names. const ( @@ -40,7 +36,7 @@ type Client struct { func NewClient(apiKey string) *Client { return &Client{ apiKey: apiKey, - BaseURL: DefaultZonomiBaseURL, + BaseURL: defaultBaseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } @@ -49,7 +45,7 @@ func NewClient(apiKey string) *Client { // ex: // - https://zonomi.com/app/dns/dyndns.jsp?action=QUERY&name=example.com&api_key=apikeyvaluehere // - https://zonomi.com/app/dns/dyndns.jsp?action=QUERY&name=**.example.com&api_key=apikeyvaluehere -func (c Client) FindTXTRecords(ctx context.Context, domain string) ([]Record, error) { +func (c *Client) FindTXTRecords(ctx context.Context, domain string) ([]Record, error) { action := ActionParameter{ Action: QueryAction, Name: domain, @@ -65,7 +61,7 @@ func (c Client) FindTXTRecords(ctx context.Context, domain string) ([]Record, er } // DoActions performs actions. -func (c Client) DoActions(ctx context.Context, actions ...ActionParameter) (*DNSAPIResult, error) { +func (c *Client) DoActions(ctx context.Context, actions ...ActionParameter) (*DNSAPIResult, error) { if len(actions) == 0 { return nil, errors.New("no action") } @@ -82,18 +78,21 @@ func (c Client) DoActions(ctx context.Context, actions ...ActionParameter) (*DNS if err != nil { return nil, err } + return resp, nil } multi := c.toMultiParameters(actions) + err := c.do(ctx, multi, resp) if err != nil { return nil, err } + return resp, nil } -func (c Client) toMultiParameters(params []ActionParameter) multiActionParameter { +func (c *Client) toMultiParameters(params []ActionParameter) multiActionParameter { multi := multiActionParameter{ APIKey: c.apiKey, } @@ -109,7 +108,7 @@ func (c Client) toMultiParameters(params []ActionParameter) multiActionParameter return multi } -func (c Client) do(ctx context.Context, params, result any) error { +func (c *Client) do(ctx context.Context, params, result any) error { baseURL, err := url.Parse(c.BaseURL) if err != nil { return err @@ -160,6 +159,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errAPI := APIError{} + err := xml.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/internal/rimuhosting/internal/client_test.go b/providers/dns/internal/rimuhosting/internal/client_test.go new file mode 100644 index 000000000..00126dfbe --- /dev/null +++ b/providers/dns/internal/rimuhosting/internal/client_test.go @@ -0,0 +1,332 @@ +package internal + +import ( + "encoding/xml" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupClient(server *httptest.Server) (*Client, error) { + client := NewClient("apikeyvaluehere") + client.BaseURL = server.URL + client.HTTPClient = server.Client() + + return client, nil +} + +func TestClient_FindTXTRecords(t *testing.T) { + testCases := []struct { + desc string + domain string + response string + query url.Values + expected []Record + }{ + { + desc: "simple", + domain: "example.com", + response: "find_records.xml", + query: url.Values{ + "name": []string{"example.com"}, + "type": []string{"TXT"}, + "action": []string{"QUERY"}, + "api_key": []string{"apikeyvaluehere"}, + }, + expected: []Record{ + { + Name: "example.org", + Type: "TXT", + Content: "txttxtx", + TTL: "3600 seconds", + Priority: "0", + }, + }, + }, + { + desc: "pattern", + domain: "**.example.com", + response: "find_records_pattern.xml", + query: url.Values{ + "name": []string{"**.example.com"}, + "type": []string{"TXT"}, + "action": []string{"QUERY"}, + "api_key": []string{"apikeyvaluehere"}, + }, + expected: []Record{ + { + Name: "_test.example.org", + Type: "TXT", + Content: "txttxtx", + TTL: "3600 seconds", + Priority: "0", + }, + { + Name: "example.org", + Type: "TXT", + Content: "txttxtx", + TTL: "3600 seconds", + Priority: "0", + }, + }, + }, + { + desc: "empty", + domain: "empty.com", + response: "find_records_empty.xml", + query: url.Values{ + "name": []string{"empty.com"}, + "type": []string{"TXT"}, + "action": []string{"QUERY"}, + "api_key": []string{"apikeyvaluehere"}, + }, + expected: nil, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", + servermock.ResponseFromFixture(test.response), + servermock.CheckQueryParameter().Strict(). + WithValues(test.query)). + Build(t) + + records, err := client.FindTXTRecords(t.Context(), test.domain) + require.NoError(t, err) + + assert.Equal(t, test.expected, records) + }) + } +} + +func TestClient_DoActions(t *testing.T) { + testCases := []struct { + desc string + actions []ActionParameter + query url.Values + response string + expected *DNSAPIResult + }{ + { + desc: "SET simple", + actions: []ActionParameter{ + NewAddRecordAction("example.org", "txttxtx", 0), + }, + response: "add_record.xml", + query: url.Values{ + "action": []string{"SET"}, + "name": []string{"example.org"}, + "type": []string{"TXT"}, + "value": []string{"txttxtx"}, + "api_key": []string{"apikeyvaluehere"}, + }, + expected: &DNSAPIResult{ + XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, + IsOk: "OK:", + ResultCounts: ResultCounts{Added: "1", Changed: "0", Unchanged: "0", Deleted: "0"}, + Actions: Actions{ + Action: Action{ + Action: "SET", + Host: "example.org", + Type: "TXT", + Records: []Record{{ + Name: "example.org", + Type: "TXT", + Content: "txttxtx", + TTL: "3600 seconds", + Priority: "0", + }}, + }, + }, + }, + }, + { + desc: "SET multiple values", + actions: []ActionParameter{ + NewAddRecordAction("example.org", "txttxtx", 0), + NewAddRecordAction("example.org", "sample", 0), + }, + response: "add_record_same_domain.xml", + query: url.Values{ + "api_key": []string{"apikeyvaluehere"}, + "action[0]": []string{"SET"}, + "name[0]": []string{"example.org"}, + "ttl[0]": []string{"0"}, + "type[0]": []string{"TXT"}, + "value[0]": []string{"txttxtx"}, + "action[1]": []string{"SET"}, + "name[1]": []string{"example.org"}, + "ttl[1]": []string{"0"}, + "type[1]": []string{"TXT"}, + "value[1]": []string{"sample"}, + }, + expected: &DNSAPIResult{ + XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, + IsOk: "OK:", + ResultCounts: ResultCounts{Added: "2", Changed: "0", Unchanged: "0", Deleted: "0"}, + Actions: Actions{ + Action: Action{ + Action: "SET", + Host: "example.org", + Type: "TXT", + Records: []Record{ + { + Name: "example.org", + Type: "TXT", + Content: "txttxtx", + TTL: "0 seconds", + Priority: "0", + }, + { + Name: "example.org", + Type: "TXT", + Content: "sample", + TTL: "0 seconds", + Priority: "0", + }, + }, + }, + }, + }, + }, + { + desc: "DELETE nothing", + actions: []ActionParameter{ + NewDeleteRecordAction("example.org", "nothing"), + }, + response: "delete_record_nothing.xml", + query: url.Values{ + "action": []string{"DELETE"}, + "name": []string{"example.org"}, + "type": []string{"TXT"}, + "value": []string{"nothing"}, + "api_key": []string{"apikeyvaluehere"}, + }, + expected: &DNSAPIResult{ + XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, + IsOk: "OK:", + ResultCounts: ResultCounts{Added: "0", Changed: "0", Unchanged: "0", Deleted: "0"}, + Actions: Actions{ + Action: Action{ + Action: "DELETE", + Host: "example.org", + Type: "TXT", + Records: nil, + }, + }, + }, + }, + { + desc: "DELETE simple", + actions: []ActionParameter{ + NewDeleteRecordAction("example.org", "txttxtx"), + }, + response: "delete_record.xml", + query: url.Values{ + "action": []string{"DELETE"}, + "name": []string{"example.org"}, + "type": []string{"TXT"}, + "value": []string{"txttxtx"}, + "api_key": []string{"apikeyvaluehere"}, + }, + expected: &DNSAPIResult{ + XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, + IsOk: "OK:", + ResultCounts: ResultCounts{Added: "0", Changed: "0", Unchanged: "0", Deleted: "1"}, + Actions: Actions{ + Action: Action{ + Action: "DELETE", + Host: "example.org", + Type: "TXT", + Records: []Record{{ + Name: "example.org", + Type: "TXT", + Content: "txttxtx", + TTL: "3600 seconds", + Priority: "0", + }}, + }, + }, + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", + servermock.ResponseFromFixture(test.response), + servermock.CheckQueryParameter().Strict(). + WithValues(test.query)). + Build(t) + + resp, err := client.DoActions(t.Context(), test.actions...) + require.NoError(t, err) + + assert.Equal(t, test.expected, resp) + }) + } +} + +func TestClient_DoActions_error(t *testing.T) { + testCases := []struct { + desc string + actions []ActionParameter + query url.Values + response string + expected string + }{ + { + desc: "SET error", + actions: []ActionParameter{ + NewAddRecordAction("example.com", "txttxtx", 0), + }, + response: "add_record_error.xml", + query: url.Values{ + "action": []string{"SET"}, + "name": []string{"example.com"}, + "type": []string{"TXT"}, + "value": []string{"txttxtx"}, + "api_key": []string{"apikeyvaluehere"}, + }, + expected: "ERROR: No zone found for example.com", + }, + { + desc: "DELETE error", + actions: []ActionParameter{ + NewDeleteRecordAction("example.com", "txttxtx"), + }, + response: "delete_record_error.xml", + query: url.Values{ + "action": []string{"DELETE"}, + "name": []string{"example.com"}, + "type": []string{"TXT"}, + "value": []string{"txttxtx"}, + "api_key": []string{"apikeyvaluehere"}, + }, + expected: "ERROR: No zone found for example.com", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", + servermock.ResponseFromFixture(test.response). + WithStatusCode(http.StatusInternalServerError), + servermock.CheckQueryParameter().Strict(). + WithValues(test.query)). + Build(t) + + _, err := client.DoActions(t.Context(), test.actions...) + require.EqualError(t, err, test.expected) + }) + } +} diff --git a/providers/dns/internal/rimuhosting/fixtures/add_record.xml b/providers/dns/internal/rimuhosting/internal/fixtures/add_record.xml similarity index 100% rename from providers/dns/internal/rimuhosting/fixtures/add_record.xml rename to providers/dns/internal/rimuhosting/internal/fixtures/add_record.xml diff --git a/providers/dns/internal/rimuhosting/fixtures/add_record_error.xml b/providers/dns/internal/rimuhosting/internal/fixtures/add_record_error.xml similarity index 100% rename from providers/dns/internal/rimuhosting/fixtures/add_record_error.xml rename to providers/dns/internal/rimuhosting/internal/fixtures/add_record_error.xml diff --git a/providers/dns/internal/rimuhosting/fixtures/add_record_same_domain.xml b/providers/dns/internal/rimuhosting/internal/fixtures/add_record_same_domain.xml similarity index 100% rename from providers/dns/internal/rimuhosting/fixtures/add_record_same_domain.xml rename to providers/dns/internal/rimuhosting/internal/fixtures/add_record_same_domain.xml diff --git a/providers/dns/internal/rimuhosting/fixtures/delete_record.xml b/providers/dns/internal/rimuhosting/internal/fixtures/delete_record.xml similarity index 100% rename from providers/dns/internal/rimuhosting/fixtures/delete_record.xml rename to providers/dns/internal/rimuhosting/internal/fixtures/delete_record.xml diff --git a/providers/dns/internal/rimuhosting/fixtures/delete_record_error.xml b/providers/dns/internal/rimuhosting/internal/fixtures/delete_record_error.xml similarity index 100% rename from providers/dns/internal/rimuhosting/fixtures/delete_record_error.xml rename to providers/dns/internal/rimuhosting/internal/fixtures/delete_record_error.xml diff --git a/providers/dns/internal/rimuhosting/fixtures/delete_record_nothing.xml b/providers/dns/internal/rimuhosting/internal/fixtures/delete_record_nothing.xml similarity index 100% rename from providers/dns/internal/rimuhosting/fixtures/delete_record_nothing.xml rename to providers/dns/internal/rimuhosting/internal/fixtures/delete_record_nothing.xml diff --git a/providers/dns/internal/rimuhosting/fixtures/find_records.xml b/providers/dns/internal/rimuhosting/internal/fixtures/find_records.xml similarity index 100% rename from providers/dns/internal/rimuhosting/fixtures/find_records.xml rename to providers/dns/internal/rimuhosting/internal/fixtures/find_records.xml diff --git a/providers/dns/internal/rimuhosting/fixtures/find_records_empty.xml b/providers/dns/internal/rimuhosting/internal/fixtures/find_records_empty.xml similarity index 100% rename from providers/dns/internal/rimuhosting/fixtures/find_records_empty.xml rename to providers/dns/internal/rimuhosting/internal/fixtures/find_records_empty.xml diff --git a/providers/dns/internal/rimuhosting/fixtures/find_records_pattern.xml b/providers/dns/internal/rimuhosting/internal/fixtures/find_records_pattern.xml similarity index 100% rename from providers/dns/internal/rimuhosting/fixtures/find_records_pattern.xml rename to providers/dns/internal/rimuhosting/internal/fixtures/find_records_pattern.xml diff --git a/providers/dns/internal/rimuhosting/types.go b/providers/dns/internal/rimuhosting/internal/types.go similarity index 98% rename from providers/dns/internal/rimuhosting/types.go rename to providers/dns/internal/rimuhosting/internal/types.go index bdb333032..c3df886a2 100644 --- a/providers/dns/internal/rimuhosting/types.go +++ b/providers/dns/internal/rimuhosting/internal/types.go @@ -1,4 +1,4 @@ -package rimuhosting +package internal import "encoding/xml" diff --git a/providers/dns/internal/rimuhosting/provider.go b/providers/dns/internal/rimuhosting/provider.go new file mode 100644 index 000000000..3be764cbf --- /dev/null +++ b/providers/dns/internal/rimuhosting/provider.go @@ -0,0 +1,107 @@ +// Package rimuhosting implements a DNS provider for solving the DNS-01 challenge using RimuHosting DNS. +package rimuhosting + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/internal/rimuhosting/internal" +) + +const DefaultTTL = 3600 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIKey string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProviderConfig return a DNSProvider instance configured for RimuHosting. +func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("the configuration of the DNS provider is nil") + } + + if config.APIKey == "" { + return nil, errors.New("incomplete credentials, missing API key") + } + + client := internal.NewClient(config.APIKey) + + if baseURL != "" { + client.BaseURL = baseURL + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{config: config, client: client}, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx := context.Background() + + records, err := d.client.FindTXTRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("failed to find record(s) for %s: %w", domain, err) + } + + actions := []internal.ActionParameter{ + internal.NewAddRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL), + } + + for _, record := range records { + actions = append(actions, internal.NewAddRecordAction(record.Name, record.Content, d.config.TTL)) + } + + _, err = d.client.DoActions(ctx, actions...) + if err != nil { + return fmt.Errorf("failed to add record(s) for %s: %w", domain, err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + action := internal.NewDeleteRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value) + + _, err := d.client.DoActions(context.Background(), action) + if err != nil { + return fmt.Errorf("failed to delete record for %s: %w", domain, err) + } + + return nil +} diff --git a/providers/dns/internal/rimuhosting/provider_test.go b/providers/dns/internal/rimuhosting/provider_test.go new file mode 100644 index 000000000..d1569af31 --- /dev/null +++ b/providers/dns/internal/rimuhosting/provider_test.go @@ -0,0 +1,46 @@ +package rimuhosting + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + expected string + apiKey string + secretKey string + }{ + { + desc: "success", + apiKey: "api_key", + secretKey: "api_secret", + }, + { + desc: "missing api key", + apiKey: "", + secretKey: "api_secret", + expected: "incomplete credentials, missing API key", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := &Config{} + config.APIKey = test.apiKey + + p, err := NewDNSProviderConfig(config, "") + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} diff --git a/providers/dns/internal/selectel/client_test.go b/providers/dns/internal/selectel/client_test.go deleted file mode 100644 index 703fd7b98..000000000 --- a/providers/dns/internal/selectel/client_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package selectel - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("token") - client.BaseURL, _ = url.Parse(server.URL) - client.HTTPClient = server.Client() - - return client, mux -} - -func TestClient_ListRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/123/records/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - fixture := "./fixtures/list_records.json" - - err := writeResponse(rw, fixture) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - records, err := client.ListRecords(context.Background(), 123) - require.NoError(t, err) - - expected := []Record{ - {ID: 123, Name: "example.com", Type: "TXT", TTL: 60, Email: "email@example.com", Content: "txttxttxtA"}, - {ID: 1234, Name: "example.org", Type: "TXT", TTL: 60, Email: "email@example.org", Content: "txttxttxtB"}, - {ID: 12345, Name: "example.net", Type: "TXT", TTL: 60, Email: "email@example.net", Content: "txttxttxtC"}, - } - - assert.Equal(t, expected, records) -} - -func TestClient_ListRecords_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/123/records/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - rw.WriteHeader(http.StatusUnauthorized) - err := writeResponse(rw, "./fixtures/error.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - records, err := client.ListRecords(context.Background(), 123) - - require.EqualError(t, err, "request failed with status code 401: API error: 400 - error description - field that the error occurred in") - assert.Nil(t, records) -} - -func TestClient_GetDomainByName(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/sub.sub.example.org", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - rw.WriteHeader(http.StatusNotFound) - }) - - mux.HandleFunc("/sub.example.org", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - rw.WriteHeader(http.StatusNotFound) - }) - - mux.HandleFunc("/example.org", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - fixture := "./fixtures/domains.json" - - err := writeResponse(rw, fixture) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - domain, err := client.GetDomainByName(context.Background(), "sub.sub.example.org") - require.NoError(t, err) - - expected := &Domain{ - ID: 123, - Name: "example.org", - } - - assert.Equal(t, expected, domain) -} - -func TestClient_AddRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/123/records/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - rec := Record{} - - err := json.NewDecoder(req.Body).Decode(&rec) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - rec.ID = 456 - - err = json.NewEncoder(rw).Encode(rec) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - record, err := client.AddRecord(context.Background(), 123, Record{ - Name: "example.org", - Type: "TXT", - TTL: 60, - Email: "email@example.org", - Content: "txttxttxttxt", - }) - - require.NoError(t, err) - - expected := &Record{ - ID: 456, - Name: "example.org", - Type: "TXT", - TTL: 60, - Email: "email@example.org", - Content: "txttxttxttxt", - } - - assert.Equal(t, expected, record) -} - -func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - }) - - err := client.DeleteRecord(context.Background(), 123, 456) - require.NoError(t, err) -} - -func writeResponse(rw io.Writer, filename string) error { - file, err := os.Open(filename) - if err != nil { - return err - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - return err -} diff --git a/providers/dns/internal/selectel/client.go b/providers/dns/internal/selectel/internal/client.go similarity index 91% rename from providers/dns/internal/selectel/client.go rename to providers/dns/internal/selectel/internal/client.go index 1e1e4a215..d441c9894 100644 --- a/providers/dns/internal/selectel/client.go +++ b/providers/dns/internal/selectel/internal/client.go @@ -1,4 +1,4 @@ -package selectel +package internal import ( "bytes" @@ -15,15 +15,11 @@ import ( "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) -// Base URL for the Selectel/VScale DNS services. -const ( - DefaultSelectelBaseURL = "https://api.selectel.ru/domains/v1" - DefaultVScaleBaseURL = "https://api.vscale.io/v1/domains" -) +const defaultBaseURL = "https://api.selectel.ru/domains/v1" const tokenHeader = "X-Token" -// Client represents DNS client. +// Client represents the DNS client. type Client struct { token string @@ -33,7 +29,7 @@ type Client struct { // NewClient returns a client instance. func NewClient(token string) *Client { - baseURL, _ := url.Parse(DefaultVScaleBaseURL) + baseURL, _ := url.Parse(defaultBaseURL) return &Client{ token: token, @@ -52,12 +48,13 @@ func (c *Client) GetDomainByName(ctx context.Context, domainName string) (*Domai } domain := &Domain{} + statusCode, err := c.do(req, domain) if err != nil { if statusCode == http.StatusNotFound && strings.Count(domainName, ".") > 1 { // Look up for the next subdomain - subIndex := strings.Index(domainName, ".") - return c.GetDomainByName(ctx, domainName[subIndex+1:]) + _, after, _ := strings.Cut(domainName, ".") + return c.GetDomainByName(ctx, after) } return nil, err @@ -74,6 +71,7 @@ func (c *Client) AddRecord(ctx context.Context, domainID int, body Record) (*Rec } record := &Record{} + _, err = c.do(req, record) if err != nil { return nil, err @@ -90,6 +88,7 @@ func (c *Client) ListRecords(ctx context.Context, domainID int) ([]Record, error } var records []Record + _, err = c.do(req, &records) if err != nil { return nil, err @@ -108,6 +107,7 @@ func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int) error } _, err = c.do(req, nil) + return err } @@ -170,6 +170,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errAPI := &APIError{} + err := json.Unmarshal(raw, errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/internal/selectel/internal/client_test.go b/providers/dns/internal/selectel/internal/client_test.go new file mode 100644 index 000000000..edabe0130 --- /dev/null +++ b/providers/dns/internal/selectel/internal/client_test.go @@ -0,0 +1,117 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupClient(server *httptest.Server) (*Client, error) { + client := NewClient("token") + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil +} + +func TestClient_ListRecords(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders()). + Route("GET /123/records/", servermock.ResponseFromFixture("list_records.json")). + Build(t) + + records, err := client.ListRecords(t.Context(), 123) + require.NoError(t, err) + + expected := []Record{ + {ID: 123, Name: "example.com", Type: "TXT", TTL: 60, Email: "email@example.com", Content: "txttxttxtA"}, + {ID: 1234, Name: "example.org", Type: "TXT", TTL: 60, Email: "email@example.org", Content: "txttxttxtB"}, + {ID: 12345, Name: "example.net", Type: "TXT", TTL: 60, Email: "email@example.net", Content: "txttxttxtC"}, + } + + assert.Equal(t, expected, records) +} + +func TestClient_ListRecords_error(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + With(tokenHeader, "token")). + Route("GET /123/records/", + servermock.ResponseFromFixture("error.json").WithStatusCode(http.StatusUnauthorized)). + Build(t) + + records, err := client.ListRecords(t.Context(), 123) + + require.EqualError(t, err, "request failed with status code 401: API error: 400 - error description - field that the error occurred in") + assert.Nil(t, records) +} + +func TestClient_GetDomainByName(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + With(tokenHeader, "token")). + Route("GET /sub.sub.example.org", + servermock.Noop().WithStatusCode(http.StatusNotFound)). + Route("GET /sub.example.org", + servermock.Noop().WithStatusCode(http.StatusNotFound)). + Route("GET /example.org", + servermock.ResponseFromFixture("domains.json")). + Build(t) + + domain, err := client.GetDomainByName(t.Context(), "sub.sub.example.org") + require.NoError(t, err) + + expected := &Domain{ + ID: 123, + Name: "example.org", + } + + assert.Equal(t, expected, domain) +} + +func TestClient_AddRecord(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + With(tokenHeader, "token")). + Route("POST /123/records/", + servermock.ResponseFromFixture("add_record.json"), + servermock.CheckRequestJSONBodyFromFixture("add_record-request.json")). + Build(t) + + record, err := client.AddRecord(t.Context(), 123, Record{ + Name: "example.org", + Type: "TXT", + TTL: 60, + Email: "email@example.org", + Content: "txttxttxttxt", + }) + + require.NoError(t, err) + + expected := &Record{ + ID: 456, + Name: "example.org", + Type: "TXT", + TTL: 60, + Email: "email@example.org", + Content: "txttxttxttxt", + } + + assert.Equal(t, expected, record) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + With(tokenHeader, "token")). + Route("DELETE /123/records/456", nil). + Build(t) + + err := client.DeleteRecord(t.Context(), 123, 456) + require.NoError(t, err) +} diff --git a/providers/dns/internal/selectel/internal/fixtures/add_record-request.json b/providers/dns/internal/selectel/internal/fixtures/add_record-request.json new file mode 100644 index 000000000..c65d3d267 --- /dev/null +++ b/providers/dns/internal/selectel/internal/fixtures/add_record-request.json @@ -0,0 +1,7 @@ +{ + "name": "example.org", + "type": "TXT", + "ttl": 60, + "email": "email@example.org", + "content": "txttxttxttxt" +} diff --git a/providers/dns/internal/selectel/internal/fixtures/add_record.json b/providers/dns/internal/selectel/internal/fixtures/add_record.json new file mode 100644 index 000000000..18a436707 --- /dev/null +++ b/providers/dns/internal/selectel/internal/fixtures/add_record.json @@ -0,0 +1,8 @@ +{ + "id": 456, + "name": "example.org", + "type": "TXT", + "ttl": 60, + "email": "email@example.org", + "content": "txttxttxttxt" +} diff --git a/providers/dns/internal/selectel/fixtures/domains.json b/providers/dns/internal/selectel/internal/fixtures/domains.json similarity index 100% rename from providers/dns/internal/selectel/fixtures/domains.json rename to providers/dns/internal/selectel/internal/fixtures/domains.json diff --git a/providers/dns/internal/selectel/fixtures/error.json b/providers/dns/internal/selectel/internal/fixtures/error.json similarity index 100% rename from providers/dns/internal/selectel/fixtures/error.json rename to providers/dns/internal/selectel/internal/fixtures/error.json diff --git a/providers/dns/internal/selectel/fixtures/list_records.json b/providers/dns/internal/selectel/internal/fixtures/list_records.json similarity index 100% rename from providers/dns/internal/selectel/fixtures/list_records.json rename to providers/dns/internal/selectel/internal/fixtures/list_records.json diff --git a/providers/dns/internal/selectel/types.go b/providers/dns/internal/selectel/internal/types.go similarity index 98% rename from providers/dns/internal/selectel/types.go rename to providers/dns/internal/selectel/internal/types.go index df7bb3fa7..e6ca792c0 100644 --- a/providers/dns/internal/selectel/types.go +++ b/providers/dns/internal/selectel/internal/types.go @@ -1,4 +1,4 @@ -package selectel +package internal import "fmt" diff --git a/providers/dns/internal/selectel/provider.go b/providers/dns/internal/selectel/provider.go new file mode 100644 index 000000000..495735736 --- /dev/null +++ b/providers/dns/internal/selectel/provider.go @@ -0,0 +1,137 @@ +// Package selectel implements a DNS provider for solving the DNS-01 challenge using Selectel Domains API. +package selectel + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/internal/selectel/internal" +) + +const MinTTL = 60 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Token string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client + + // TODO(ldez): remove in v5? + BaseURL string +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProviderConfig return a DNSProvider instance configured for selectel. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("the configuration of the DNS provider is nil") + } + + if config.Token == "" { + return nil, errors.New("credentials missing") + } + + if config.TTL < MinTTL { + return nil, fmt.Errorf("invalid TTL, TTL (%d) must be greater than %d", config.TTL, MinTTL) + } + + client := internal.NewClient(config.Token) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + var err error + + client.BaseURL, err = url.Parse(config.BaseURL) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + return &DNSProvider{config: config, client: client}, nil +} + +// Timeout returns the Timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record to fulfill DNS-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx := context.Background() + + // TODO(ldez) replace domain by FQDN to follow CNAME. + domainObj, err := d.client.GetDomainByName(ctx, domain) + if err != nil { + return fmt.Errorf("get domain by name: %w", err) + } + + txtRecord := internal.Record{ + Type: "TXT", + TTL: d.config.TTL, + Name: info.EffectiveFQDN, + Content: info.Value, + } + + _, err = d.client.AddRecord(ctx, domainObj.ID, txtRecord) + if err != nil { + return fmt.Errorf("add record: %w", err) + } + + return nil +} + +// CleanUp removes a TXT record used for DNS-01 challenge. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + recordName := dns01.UnFqdn(info.EffectiveFQDN) + + ctx := context.Background() + + // TODO(ldez) replace domain by FQDN to follow CNAME. + domainObj, err := d.client.GetDomainByName(ctx, domain) + if err != nil { + return fmt.Errorf("%w", err) + } + + records, err := d.client.ListRecords(ctx, domainObj.ID) + if err != nil { + return fmt.Errorf("list records: %w", err) + } + + // Delete records with specific FQDN + var lastErr error + + for _, record := range records { + if record.Name == recordName { + err = d.client.DeleteRecord(ctx, domainObj.ID, record.ID) + if err != nil { + lastErr = fmt.Errorf("delete record: %w", err) + } + } + } + + return lastErr +} diff --git a/providers/dns/internal/selectel/provider_test.go b/providers/dns/internal/selectel/provider_test.go new file mode 100644 index 000000000..75a032bf4 --- /dev/null +++ b/providers/dns/internal/selectel/provider_test.go @@ -0,0 +1,55 @@ +package selectel + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + token string + ttl int + expected string + }{ + { + desc: "success", + token: "123", + ttl: 60, + }, + { + desc: "missing api key", + token: "", + ttl: 60, + expected: "credentials missing", + }, + { + desc: "bad TTL value", + token: "123", + ttl: 59, + expected: fmt.Sprintf("invalid TTL, TTL (59) must be greater than %d", MinTTL), + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := &Config{} + config.TTL = test.ttl + config.Token = test.token + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + assert.NotNil(t, p.config) + assert.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} diff --git a/providers/dns/internal/tecnocratica/internal/client.go b/providers/dns/internal/tecnocratica/internal/client.go new file mode 100644 index 000000000..5a529fa2f --- /dev/null +++ b/providers/dns/internal/tecnocratica/internal/client.go @@ -0,0 +1,182 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +// defaultBaseURL is the default API endpoint. +const defaultBaseURL = "https://api.neodigit.net/v1" + +// Client is a Tecnocrática API client. +type Client struct { + token string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(token string) (*Client, error) { + if token == "" { + return nil, errors.New("credentials missing: token") + } + + baseURL, err := url.Parse(defaultBaseURL) + if err != nil { + return nil, err + } + + return &Client{ + token: token, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + }, nil +} + +// GetZones lists all DNS zones. +func (c *Client) GetZones(ctx context.Context) ([]Zone, error) { + endpoint := c.BaseURL.JoinPath("dns", "zones") + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var zones []Zone + + err = c.do(req, &zones) + if err != nil { + return nil, err + } + + return zones, nil +} + +// GetRecords lists all records in a zone. +func (c *Client) GetRecords(ctx context.Context, zoneID int, recordType string) ([]Record, error) { + endpoint := c.BaseURL.JoinPath("dns", "zones", strconv.Itoa(zoneID), "records") + + if recordType != "" { + query := endpoint.Query() + query.Set("type", recordType) + endpoint.RawQuery = query.Encode() + } + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var records []Record + + err = c.do(req, &records) + if err != nil { + return nil, err + } + + return records, nil +} + +// CreateRecord creates a new DNS record. +func (c *Client) CreateRecord(ctx context.Context, zoneID int, record Record) (*Record, error) { + endpoint := c.BaseURL.JoinPath("dns", "zones", strconv.Itoa(zoneID), "records") + + payload := RecordRequest{Record: record} + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload) + if err != nil { + return nil, err + } + + var result Record + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// DeleteRecord deletes a DNS record. +func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID int) error { + endpoint := c.BaseURL.JoinPath("dns", "zones", strconv.Itoa(zoneID), "records", strconv.Itoa(recordID)) + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) do(req *http.Request, result any) error { + useragent.SetHeader(req.Header) + + req.Header.Set("X-TCpanel-Token", c.token) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + raw, _ := io.ReadAll(resp.Body) + + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} diff --git a/providers/dns/internal/tecnocratica/internal/client_test.go b/providers/dns/internal/tecnocratica/internal/client_test.go new file mode 100644 index 000000000..4e9cf3e85 --- /dev/null +++ b/providers/dns/internal/tecnocratica/internal/client_test.go @@ -0,0 +1,174 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With("X-TCpanel-Token", "secret")) +} + +func TestClient_GetZones(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/zones", + servermock.ResponseFromFixture("get_zones.json")). + Build(t) + + zones, err := client.GetZones(t.Context()) + require.NoError(t, err) + + expected := []Zone{ + { + ID: 6, + Name: "example.com", + HumanName: "example.com", + }, + { + ID: 7, + Name: "example.org", + HumanName: "example.org", + }, + } + + assert.Equal(t, expected, zones) +} + +func TestClient_GetZones_error(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/zones", + servermock.RawStringResponse(`{"error": "unauthorized"}`). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + zones, err := client.GetZones(t.Context()) + require.Error(t, err) + + assert.Nil(t, zones) +} + +func TestClient_GetRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/zones/6/records", + servermock.ResponseFromFixture("get_records.json")). + Build(t) + + records, err := client.GetRecords(t.Context(), 6, "") + require.NoError(t, err) + + expected := []Record{ + { + ID: 98, + Name: "", + Type: "SOA", + Content: "ns1.example.org dns.example.org 2015092102 7200 7200 1209600 1800", + TTL: 7200, + }, + { + ID: 99, + Name: "", + Type: "NS", + Content: "ns1.example.org", + TTL: 7200, + }, + { + ID: 100, + Name: "_acme-challenge", + Type: "TXT", + Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 120, + }, + } + + assert.Equal(t, expected, records) +} + +func TestClient_CreateRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/zones/6/records", + servermock.ResponseFromFixture("create_record.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). + Build(t) + + record := Record{ + Name: "_acme-challenge", + Type: "TXT", + Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 120, + } + + result, err := client.CreateRecord(t.Context(), 6, record) + require.NoError(t, err) + + expected := &Record{ + ID: 101, + Name: "_acme-challenge", + Type: "TXT", + Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 120, + } + + assert.Equal(t, expected, result) +} + +func TestClient_CreateRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/zones/6/records", + servermock.RawStringResponse(`{"error": "bad request"}`). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + record := Record{ + Name: "_acme-challenge", + Type: "TXT", + Content: "test-value", + TTL: 120, + } + + result, err := client.CreateRecord(t.Context(), 6, record) + require.Error(t, err) + + assert.Nil(t, result) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /dns/zones/6/records/101", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) + + err := client.DeleteRecord(t.Context(), 6, 101) + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /dns/zones/6/records/999", + servermock.RawStringResponse(`{"error": "not found"}`). + WithStatusCode(http.StatusNotFound)). + Build(t) + + err := client.DeleteRecord(t.Context(), 6, 999) + require.Error(t, err) +} diff --git a/providers/dns/internal/tecnocratica/internal/fixtures/create_record-request.json b/providers/dns/internal/tecnocratica/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..4cd339c98 --- /dev/null +++ b/providers/dns/internal/tecnocratica/internal/fixtures/create_record-request.json @@ -0,0 +1,8 @@ +{ + "record": { + "name": "_acme-challenge", + "type": "TXT", + "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 120 + } +} diff --git a/providers/dns/internal/tecnocratica/internal/fixtures/create_record.json b/providers/dns/internal/tecnocratica/internal/fixtures/create_record.json new file mode 100644 index 000000000..6f30010ac --- /dev/null +++ b/providers/dns/internal/tecnocratica/internal/fixtures/create_record.json @@ -0,0 +1,10 @@ +{ + "id": 101, + "name": "_acme-challenge", + "type": "TXT", + "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 120, + "prio": null, + "created_at": "2015-09-21T14:40:27.127+02:00", + "updated_at": "2015-09-21T14:40:27.127+02:00" +} diff --git a/providers/dns/internal/tecnocratica/internal/fixtures/get_records.json b/providers/dns/internal/tecnocratica/internal/fixtures/get_records.json new file mode 100644 index 000000000..00e09c37f --- /dev/null +++ b/providers/dns/internal/tecnocratica/internal/fixtures/get_records.json @@ -0,0 +1,26 @@ +[ + { + "id": 98, + "name": "", + "type": "SOA", + "content": "ns1.example.org dns.example.org 2015092102 7200 7200 1209600 1800", + "ttl": 7200, + "prio": null + }, + { + "id": 99, + "name": "", + "type": "NS", + "content": "ns1.example.org", + "ttl": 7200, + "prio": null + }, + { + "id": 100, + "name": "_acme-challenge", + "type": "TXT", + "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 120, + "prio": null + } +] diff --git a/providers/dns/internal/tecnocratica/internal/fixtures/get_zones.json b/providers/dns/internal/tecnocratica/internal/fixtures/get_zones.json new file mode 100644 index 000000000..01a08dced --- /dev/null +++ b/providers/dns/internal/tecnocratica/internal/fixtures/get_zones.json @@ -0,0 +1,16 @@ +[ + { + "id": 6, + "name": "example.com", + "created_at": "2015-09-21T12:19:04.000+02:00", + "updated_at": "2015-09-21T12:19:04.000+02:00", + "human_name": "example.com" + }, + { + "id": 7, + "name": "example.org", + "created_at": "2015-09-22T10:00:00.000+02:00", + "updated_at": "2015-09-22T10:00:00.000+02:00", + "human_name": "example.org" + } +] diff --git a/providers/dns/internal/tecnocratica/internal/types.go b/providers/dns/internal/tecnocratica/internal/types.go new file mode 100644 index 000000000..505bfbced --- /dev/null +++ b/providers/dns/internal/tecnocratica/internal/types.go @@ -0,0 +1,23 @@ +package internal + +// Zone represents a DNS zone. +type Zone struct { + ID int `json:"id"` + Name string `json:"name"` + HumanName string `json:"human_name"` +} + +// Record represents a DNS record. +type Record struct { + ID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Content string `json:"content,omitempty"` + TTL int `json:"ttl,omitempty"` + Priority int `json:"prio,omitempty"` +} + +// RecordRequest is the request body for creating/updating a record. +type RecordRequest struct { + Record Record `json:"record"` +} diff --git a/providers/dns/internal/tecnocratica/provider.go b/providers/dns/internal/tecnocratica/provider.go new file mode 100644 index 000000000..17cfb8379 --- /dev/null +++ b/providers/dns/internal/tecnocratica/provider.go @@ -0,0 +1,165 @@ +// Package tecnocratica implements a DNS provider for solving the DNS-01 challenge using Tecnocrática. +package tecnocratica + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/internal/tecnocratica/internal" +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Token string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + zoneIDs map[string]int + recordIDs map[string]int + recordIDsMu sync.Mutex +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Tecnocrática. +func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("the configuration of the DNS provider is nil") + } + + if config.Token == "" { + return nil, errors.New("missing credentials") + } + + client, err := internal.NewClient(config.Token) + if err != nil { + return nil, fmt.Errorf("create client: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + if baseURL != "" { + client.BaseURL, err = url.Parse(baseURL) + if err != nil { + return nil, err + } + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + zoneIDs: make(map[string]int), + recordIDs: make(map[string]int), + }, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("could not find zone for domain %q: %w", domain, err) + } + + authZone = dns01.UnFqdn(authZone) + + zone, err := d.findZone(ctx, authZone) + if err != nil { + return fmt.Errorf("%w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("%w", err) + } + + record := internal.Record{ + Name: subDomain, + Type: "TXT", + Content: info.Value, + TTL: d.config.TTL, + } + + newRecord, err := d.client.CreateRecord(ctx, zone.ID, record) + if err != nil { + return fmt.Errorf("create record: %w", err) + } + + d.recordIDsMu.Lock() + d.zoneIDs[token] = zone.ID + d.recordIDs[token] = newRecord.ID + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + d.recordIDsMu.Lock() + zoneID, zoneOK := d.zoneIDs[token] + recordID, recordOK := d.recordIDs[token] + d.recordIDsMu.Unlock() + + if !zoneOK || !recordOK { + return fmt.Errorf("unknown record ID or zone ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + err := d.client.DeleteRecord(context.Background(), zoneID, recordID) + if err != nil { + return fmt.Errorf("delete record: fqdn=%s, zoneID=%d, recordID=%d: %w", + info.EffectiveFQDN, zoneID, recordID, err) + } + + d.recordIDsMu.Lock() + delete(d.zoneIDs, token) + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + + return nil +} + +func (d *DNSProvider) findZone(ctx context.Context, zoneName string) (*internal.Zone, error) { + zones, err := d.client.GetZones(ctx) + if err != nil { + return nil, fmt.Errorf("get zones: %w", err) + } + + for _, zone := range zones { + if zone.Name == zoneName || zone.HumanName == zoneName { + return &zone, nil + } + } + + return nil, fmt.Errorf("zone not found: %s", zoneName) +} diff --git a/providers/dns/internal/tecnocratica/provider_test.go b/providers/dns/internal/tecnocratica/provider_test.go new file mode 100644 index 000000000..33e5f7c67 --- /dev/null +++ b/providers/dns/internal/tecnocratica/provider_test.go @@ -0,0 +1,99 @@ +package tecnocratica + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + token string + expected string + }{ + { + desc: "success", + token: "secret", + }, + { + desc: "missing token", + expected: "missing credentials", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := &Config{} + config.Token = test.token + + p, err := NewDNSProviderConfig(config, "") + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := &Config{ + Token: "secret", + PropagationTimeout: 10 * time.Second, + PollingInterval: 1 * time.Second, + TTL: 120, + HTTPClient: server.Client(), + } + + p, err := NewDNSProviderConfig(config, server.URL) + if err != nil { + return nil, err + } + + return p, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With("X-TCpanel-Token", "secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("GET /dns/zones", + servermock.ResponseFromInternal("get_zones.json")). + Route("POST /dns/zones/6/records", + servermock.ResponseFromInternal("create_record.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromInternal("create_record-request.json")). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("DELETE /dns/zones/456/records/123", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) + + token := "abc" + + provider.recordIDs[token] = 123 + provider.zoneIDs[token] = 456 + + err := provider.CleanUp("example.com", token, "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/internal/useragent/useragent.go b/providers/dns/internal/useragent/useragent.go index ced955892..090c9109a 100644 --- a/providers/dns/internal/useragent/useragent.go +++ b/providers/dns/internal/useragent/useragent.go @@ -10,12 +10,12 @@ import ( const ( // ourUserAgent is the User-Agent of this underlying library package. - ourUserAgent = "goacme-lego/4.20.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/internal/westcn/internal/client.go b/providers/dns/internal/westcn/internal/client.go new file mode 100644 index 000000000..621c7865f --- /dev/null +++ b/providers/dns/internal/westcn/internal/client.go @@ -0,0 +1,211 @@ +package internal + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + querystring "github.com/google/go-querystring/query" + "golang.org/x/text/encoding" + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/transform" +) + +const defaultBaseURL = "https://api.west.cn/api/v2" + +// Client the West.cn API client. +type Client struct { + username string + password string + + encoder *encoding.Encoder + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(username, password string) (*Client, error) { + if username == "" || password == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + username: username, + password: password, + encoder: simplifiedchinese.GBK.NewEncoder(), + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// AddRecord adds a record. +// https://www.west.cn/CustomerCenter/doc/domain_v2.html#37u3001u6dfbu52a0u57dfu540du89e3u67900a3ca20id3d37u3001u6dfbu52a0u57dfu540du89e3u67903e203ca3e +func (c *Client) AddRecord(ctx context.Context, record Record) (int, error) { + values, err := querystring.Values(record) + if err != nil { + return 0, err + } + + req, err := c.newRequest(ctx, "domain", "adddnsrecord", values) + if err != nil { + return 0, err + } + + results := &APIResponse[RecordID]{} + + err = c.do(req, results) + if err != nil { + return 0, err + } + + if results.Result != http.StatusOK { + return 0, results + } + + return results.Data.ID, nil +} + +// DeleteRecord deleted a record. +// https://www.west.cn/CustomerCenter/doc/domain_v2.html#39u3001u5220u9664u57dfu540du89e3u67900a3ca20id3d39u3001u5220u9664u57dfu540du89e3u67903e203ca3e +func (c *Client) DeleteRecord(ctx context.Context, domain string, recordID int) error { + values := url.Values{} + values.Set("domain", domain) + values.Set("id", strconv.Itoa(recordID)) + + req, err := c.newRequest(ctx, "domain", "deldnsrecord", values) + if err != nil { + return err + } + + results := &APIResponse[any]{} + + err = c.do(req, results) + if err != nil { + return err + } + + if results.Result != http.StatusOK { + return results + } + + return nil +} + +func (c *Client) newRequest(ctx context.Context, p, act string, form url.Values) (*http.Request, error) { + if form == nil { + form = url.Values{} + } + + c.sign(form, time.Now()) + + values, err := c.convertURLValues(form) + if err != nil { + return nil, err + } + + endpoint := c.BaseURL.JoinPath(p, "/") + + query := endpoint.Query() + query.Set("act", act) + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(values.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + return req, nil +} + +func (c *Client) sign(form url.Values, now time.Time) { + timestamp := strconv.FormatInt(now.UnixMilli(), 10) + + sum := md5.Sum([]byte(c.username + c.password + timestamp)) + + form.Set("token", hex.EncodeToString(sum[:])) + form.Set("username", c.username) + form.Set("time", timestamp) +} + +func (c *Client) do(req *http.Request, result any) error { + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = gbkDecoder(raw).Decode(result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func (c *Client) convertURLValues(values url.Values) (url.Values, error) { + results := make(url.Values) + + for key, vs := range values { + encKey, err := c.encoder.String(key) + if err != nil { + return nil, err + } + + for _, value := range vs { + encValue, err := c.encoder.String(value) + if err != nil { + return nil, err + } + + results.Add(encKey, encValue) + } + } + + return results, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + result := &APIResponse[any]{} + + err := gbkDecoder(raw).Decode(result) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return result +} + +func gbkDecoder(raw []byte) *json.Decoder { + return json.NewDecoder(transform.NewReader(bytes.NewBuffer(raw), simplifiedchinese.GBK.NewDecoder())) +} diff --git a/providers/dns/internal/westcn/internal/client_test.go b/providers/dns/internal/westcn/internal/client_test.go new file mode 100644 index 000000000..53fd6ed8f --- /dev/null +++ b/providers/dns/internal/westcn/internal/client_test.go @@ -0,0 +1,167 @@ +package internal + +import ( + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/encoding/simplifiedchinese" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("user", "secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + client.BaseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()) +} + +func TestClientAddRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /domain/", + servermock.ResponseFromFixture("adddnsrecord.json"). + WithHeader("Content-Type", "application/json", "Charset=gb2312"), + servermock.CheckQueryParameter().Strict(). + With("act", "adddnsrecord"), + servermock.CheckForm().UsePostForm().Strict(). + With("domain", "example.com"). + With("host", "@"). + With("ttl", "60"). + With("type", "TXT"). + With("value", "txtTXTtxt"). + // With("act", "adddnsrecord"). + With("username", "user"). + WithRegexp("time", `\d+`). + WithRegexp("token", `[a-z0-9]{32}`), + ). + Build(t) + + record := Record{ + Domain: "example.com", + Host: "@", + Type: "TXT", + Value: "txtTXTtxt", + TTL: 60, + } + + id, err := client.AddRecord(t.Context(), record) + require.NoError(t, err) + + assert.Equal(t, 123456, id) +} + +func TestClientAddRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /domain/", + servermock.ResponseFromFixture("error.json"). + WithHeader("Content-Type", "application/json", "Charset=gb2312"), + servermock.CheckQueryParameter().Strict(). + With("act", "adddnsrecord"), + ). + Build(t) + + record := Record{ + Domain: "example.com", + Host: "@", + Type: "TXT", + Value: "txtTXTtxt", + TTL: 60, + } + + _, err := client.AddRecord(t.Context(), record) + require.Error(t, err) + + require.EqualError(t, err, "10000: username,time,token必传 (500)") +} + +func TestClientDeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /domain/", + servermock.ResponseFromFixture("deldnsrecord.json"). + WithHeader("Content-Type", "application/json", "Charset=gb2312"), + servermock.CheckQueryParameter().Strict(). + With("act", "deldnsrecord"), + servermock.CheckForm().UsePostForm().Strict(). + With("id", "123"). + With("domain", "example.com"). + With("username", "user"). + WithRegexp("time", `\d+`). + WithRegexp("token", `[a-z0-9]{32}`), + ). + Build(t) + + err := client.DeleteRecord(t.Context(), "example.com", 123) + require.NoError(t, err) +} + +func TestClientDeleteRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /domain/", + servermock.ResponseFromFixture("error.json"). + WithHeader("Content-Type", "application/json", "Charset=gb2312"), + servermock.CheckQueryParameter().Strict(). + With("act", "deldnsrecord"), + ). + Build(t) + err := client.DeleteRecord(t.Context(), "example.com", 123) + require.Error(t, err) + + require.EqualError(t, err, "10000: username,time,token必传 (500)") +} + +func Test_convertURLValues(t *testing.T) { + client, err := NewClient("user", "secret") + require.NoError(t, err) + + key := "你好abc" + value := "世界def" + + form := url.Values{} + form.Set(key, value) + + values, err := client.convertURLValues(form) + require.NoError(t, err) + + encoder := simplifiedchinese.GBK.NewEncoder() + + k, err := encoder.String(key) + require.NoError(t, err) + + v, err := encoder.String(value) + require.NoError(t, err) + + assert.Equal(t, v, values.Get(k)) + + decoder := simplifiedchinese.GBK.NewDecoder() + + decValue, err := decoder.String(values.Get(k)) + require.NoError(t, err) + + assert.Equal(t, value, decValue) +} + +func TestClient_sign(t *testing.T) { + client, err := NewClient("zhangsan", "5dh232kfg!*") + require.NoError(t, err) + + form := url.Values{} + + client.sign(form, time.UnixMilli(1554691950854)) + + assert.Equal(t, "zhangsan", form.Get("username")) + assert.Equal(t, "1554691950854", form.Get("time")) + assert.Equal(t, "f17581fb2535b2a7ee4468eb3f96a2a9", form.Get("token")) +} diff --git a/providers/dns/internal/westcn/internal/fixtures/adddnsrecord.json b/providers/dns/internal/westcn/internal/fixtures/adddnsrecord.json new file mode 100644 index 000000000..f1c135206 --- /dev/null +++ b/providers/dns/internal/westcn/internal/fixtures/adddnsrecord.json @@ -0,0 +1,7 @@ +{ + "result": 200, + "clientid": "54880064508339547956", + "data": { + "id": 123456 + } +} diff --git a/providers/dns/internal/westcn/internal/fixtures/deldnsrecord.json b/providers/dns/internal/westcn/internal/fixtures/deldnsrecord.json new file mode 100644 index 000000000..e97e92f74 --- /dev/null +++ b/providers/dns/internal/westcn/internal/fixtures/deldnsrecord.json @@ -0,0 +1,4 @@ +{ + "result": 200, + "clientid": "54880064508339547956" +} diff --git a/providers/dns/internal/westcn/internal/fixtures/error.json b/providers/dns/internal/westcn/internal/fixtures/error.json new file mode 100644 index 000000000..1c92415de --- /dev/null +++ b/providers/dns/internal/westcn/internal/fixtures/error.json @@ -0,0 +1,6 @@ +{ + "result": 500, + "clientid": "54880064508339547956", + "msg": "username,time,tokenش", + "errcode": 10000 +} diff --git a/providers/dns/internal/westcn/internal/types.go b/providers/dns/internal/westcn/internal/types.go new file mode 100644 index 000000000..d8d66be2c --- /dev/null +++ b/providers/dns/internal/westcn/internal/types.go @@ -0,0 +1,28 @@ +package internal + +import "fmt" + +type APIResponse[T any] struct { + Result int `json:"result,omitempty"` + ClientID string `json:"clientid,omitempty"` + Message string `json:"msg,omitempty"` + ErrorCode int `json:"errcode,omitempty"` + Data T `json:"data,omitempty"` +} + +func (a APIResponse[T]) Error() string { + return fmt.Sprintf("%d: %s (%d)", a.ErrorCode, a.Message, a.Result) +} + +type Record struct { + Domain string `url:"domain,omitempty"` + Host string `url:"host,omitempty"` + Type string `url:"type,omitempty"` + Value string `url:"value,omitempty"` + TTL int `url:"ttl,omitempty"` // 60~86400 seconds + Priority int `url:"level,omitempty"` +} + +type RecordID struct { + ID int `json:"id,omitempty"` +} diff --git a/providers/dns/internal/westcn/provider.go b/providers/dns/internal/westcn/provider.go new file mode 100644 index 000000000..a9e6dad58 --- /dev/null +++ b/providers/dns/internal/westcn/provider.go @@ -0,0 +1,140 @@ +package westcn + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/internal/westcn/internal" +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Username string + Password string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + recordIDs map[string]int + recordIDsMu sync.Mutex +} + +// NewDNSProviderConfig return a DNSProvider instance configured for West.cn/西部数码. +func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.Username, config.Password) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + if baseURL != "" { + client.BaseURL, err = url.Parse(baseURL) + if err != nil { + return nil, err + } + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]int), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("%w", err) + } + + record := internal.Record{ + Domain: dns01.UnFqdn(authZone), + Host: subDomain, + Type: "TXT", + Value: info.Value, + TTL: d.config.TTL, + } + + recordID, err := d.client.AddRecord(context.Background(), record) + if err != nil { + return fmt.Errorf("add record: %w", err) + } + + d.recordIDsMu.Lock() + d.recordIDs[token] = recordID + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("could not find zone for domain %q: %w", domain, err) + } + + // gets the record's unique ID + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + + if !ok { + return fmt.Errorf("unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID) + if err != nil { + return fmt.Errorf("delete record: %w", err) + } + + // deletes record ID from map + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/internal/westcn/provider_test.go b/providers/dns/internal/westcn/provider_test.go new file mode 100644 index 000000000..2ae0f09cb --- /dev/null +++ b/providers/dns/internal/westcn/provider_test.go @@ -0,0 +1,127 @@ +package westcn + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + username string + password string + expected string + }{ + { + desc: "success", + username: "user", + password: "secret", + }, + { + desc: "missing username", + password: "secret", + expected: "credentials missing", + }, + { + desc: "missing password", + username: "user", + expected: "credentials missing", + }, + { + desc: "missing credentials", + expected: "credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := &Config{} + config.Username = test.username + config.Password = test.password + + p, err := NewDNSProviderConfig(config, "") + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := &Config{ + Username: "user", + Password: "secret", + PropagationTimeout: 10 * time.Second, + PollingInterval: 1 * time.Second, + TTL: 120, + HTTPClient: server.Client(), + } + + p, err := NewDNSProviderConfig(config, server.URL) + if err != nil { + return nil, err + } + + return p, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("POST /domain/", + servermock.ResponseFromInternal("adddnsrecord.json"). + WithHeader("Content-Type", "application/json", "Charset=gb2312"), + servermock.CheckQueryParameter().Strict(). + With("act", "adddnsrecord"), + servermock.CheckForm().UsePostForm().Strict(). + With("domain", "example.com"). + With("host", "_acme-challenge"). + With("ttl", "120"). + With("type", "TXT"). + With("value", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"). + // With("act", "adddnsrecord"). + With("username", "user"). + WithRegexp("time", `\d+`). + WithRegexp("token", `[a-z0-9]{32}`), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("POST /domain/", + servermock.ResponseFromInternal("deldnsrecord.json"). + WithHeader("Content-Type", "application/json", "Charset=gb2312"), + servermock.CheckQueryParameter().Strict(). + With("act", "deldnsrecord"), + servermock.CheckForm().UsePostForm().Strict(). + With("id", "123"). + With("domain", "example.com"). + With("username", "user"). + WithRegexp("time", `\d+`). + WithRegexp("token", `[a-z0-9]{32}`), + ). + Build(t) + + provider.recordIDs["abc"] = 123 + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/internetbs/internal/client.go b/providers/dns/internetbs/internal/client.go index 771408c5d..cf9e90dc5 100644 --- a/providers/dns/internetbs/internal/client.go +++ b/providers/dns/internetbs/internal/client.go @@ -34,7 +34,7 @@ type Client struct { } // NewClient creates a new Client. -func NewClient(apiKey string, password string) *Client { +func NewClient(apiKey, password string) *Client { baseURL, _ := url.Parse(baseURL) return &Client{ @@ -46,8 +46,9 @@ func NewClient(apiKey string, password string) *Client { } // AddRecord The command is intended to add a new DNS record to a specific zone (domain). -func (c Client) AddRecord(ctx context.Context, query RecordQuery) error { +func (c *Client) AddRecord(ctx context.Context, query RecordQuery) error { var r APIResponse + err := c.doRequest(ctx, "Add", query, &r) if err != nil { return err @@ -61,8 +62,9 @@ func (c Client) AddRecord(ctx context.Context, query RecordQuery) error { } // RemoveRecord The command is intended to remove a DNS record from a specific zone. -func (c Client) RemoveRecord(ctx context.Context, query RecordQuery) error { +func (c *Client) RemoveRecord(ctx context.Context, query RecordQuery) error { var r APIResponse + err := c.doRequest(ctx, "Remove", query, &r) if err != nil { return err @@ -76,8 +78,9 @@ func (c Client) RemoveRecord(ctx context.Context, query RecordQuery) error { } // ListRecords The command is intended to retrieve the list of DNS records for a specific domain. -func (c Client) ListRecords(ctx context.Context, query ListRecordQuery) ([]Record, error) { +func (c *Client) ListRecords(ctx context.Context, query ListRecordQuery) ([]Record, error) { var l ListResponse + err := c.doRequest(ctx, "List", query, &l) if err != nil { return nil, err @@ -90,7 +93,7 @@ func (c Client) ListRecords(ctx context.Context, query ListRecordQuery) ([]Recor return l.Records, nil } -func (c Client) doRequest(ctx context.Context, action string, params any, result any) error { +func (c *Client) doRequest(ctx context.Context, action string, params, result any) error { endpoint := c.baseURL.JoinPath("Domain", "DnsRecord", action) values, err := querystring.Values(params) diff --git a/providers/dns/internetbs/internal/client_test.go b/providers/dns/internetbs/internal/client_test.go index a22f1b121..4532426d5 100644 --- a/providers/dns/internetbs/internal/client_test.go +++ b/providers/dns/internetbs/internal/client_test.go @@ -1,16 +1,14 @@ package internal import ( - "context" "fmt" - "io" - "net/http" "net/http/httptest" "net/url" "os" "strconv" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -22,8 +20,33 @@ const ( testPassword = "testpass" ) +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(testAPIKey, testPassword) + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + ) +} + func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "/Domain/DnsRecord/Add", "./fixtures/Domain_DnsRecord_Add_SUCCESS.json") + client := mockBuilder(). + Route("POST /Domain/DnsRecord/Add", + servermock.ResponseFromFixture("Domain_DnsRecord_Add_SUCCESS.json"), + servermock.CheckForm().Strict(). + With("fullrecordname", "www.example.com"). + With("ttl", "36000"). + With("type", "TXT"). + With("value", "xxx"). + With("password", testPassword). + With("apiKey", testAPIKey). + With("ResponseFormat", "JSON")). + Build(t) query := RecordQuery{ FullRecordName: "www.example.com", @@ -32,12 +55,15 @@ func TestClient_AddRecord(t *testing.T) { TTL: 36000, } - err := client.AddRecord(context.Background(), query) + err := client.AddRecord(t.Context(), query) require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "/Domain/DnsRecord/Add", "./fixtures/Domain_DnsRecord_Add_FAILURE.json") + client := mockBuilder(). + Route("POST /Domain/DnsRecord/Add", + servermock.ResponseFromFixture("Domain_DnsRecord_Add_FAILURE.json")). + Build(t) query := RecordQuery{ FullRecordName: "www.example.com.", @@ -46,7 +72,7 @@ func TestClient_AddRecord_error(t *testing.T) { TTL: 36000, } - err := client.AddRecord(context.Background(), query) + err := client.AddRecord(t.Context(), query) require.Error(t, err) } @@ -67,7 +93,7 @@ func TestClient_AddRecord_integration(t *testing.T) { TTL: 36000, } - err := client.AddRecord(context.Background(), query) + err := client.AddRecord(t.Context(), query) require.NoError(t, err) query = RecordQuery{ @@ -77,31 +103,43 @@ func TestClient_AddRecord_integration(t *testing.T) { TTL: 36000, } - err = client.AddRecord(context.Background(), query) + err = client.AddRecord(t.Context(), query) require.NoError(t, err) } func TestClient_RemoveRecord(t *testing.T) { - client := setupTest(t, "/Domain/DnsRecord/Remove", "./fixtures/Domain_DnsRecord_Remove_SUCCESS.json") + client := mockBuilder(). + Route("POST /Domain/DnsRecord/Remove", + servermock.ResponseFromFixture("Domain_DnsRecord_Remove_SUCCESS.json"), + servermock.CheckForm().Strict(). + With("fullrecordname", "www.example.com"). + With("type", "TXT"). + With("password", testPassword). + With("apiKey", testAPIKey). + With("ResponseFormat", "JSON")). + Build(t) query := RecordQuery{ FullRecordName: "www.example.com", Type: "TXT", Value: "", } - err := client.RemoveRecord(context.Background(), query) + err := client.RemoveRecord(t.Context(), query) require.NoError(t, err) } func TestClient_RemoveRecord_error(t *testing.T) { - client := setupTest(t, "/Domain/DnsRecord/Remove", "./fixtures/Domain_DnsRecord_Remove_FAILURE.json") + client := mockBuilder(). + Route("POST /Domain/DnsRecord/Remove", + servermock.ResponseFromFixture("Domain_DnsRecord_Remove_FAILURE.json")). + Build(t) query := RecordQuery{ FullRecordName: "www.example.com.", Type: "TXT", Value: "", } - err := client.RemoveRecord(context.Background(), query) + err := client.RemoveRecord(t.Context(), query) require.Error(t, err) } @@ -121,18 +159,26 @@ func TestClient_RemoveRecord_integration(t *testing.T) { Value: "", } - err := client.RemoveRecord(context.Background(), query) + err := client.RemoveRecord(t.Context(), query) require.NoError(t, err) } func TestClient_ListRecords(t *testing.T) { - client := setupTest(t, "/Domain/DnsRecord/List", "./fixtures/Domain_DnsRecord_List_SUCCESS.json") + client := mockBuilder(). + Route("POST /Domain/DnsRecord/List", + servermock.ResponseFromFixture("Domain_DnsRecord_List_SUCCESS.json"), + servermock.CheckForm().Strict(). + With("Domain", "example.com"). + With("password", testPassword). + With("apiKey", testAPIKey). + With("ResponseFormat", "JSON")). + Build(t) query := ListRecordQuery{ Domain: "example.com", } - records, err := client.ListRecords(context.Background(), query) + records, err := client.ListRecords(t.Context(), query) require.NoError(t, err) expected := []Record{ @@ -178,13 +224,16 @@ func TestClient_ListRecords(t *testing.T) { } func TestClient_ListRecords_error(t *testing.T) { - client := setupTest(t, "/Domain/DnsRecord/List", "./fixtures/Domain_DnsRecord_List_FAILURE.json") + client := mockBuilder(). + Route("POST /Domain/DnsRecord/List", + servermock.ResponseFromFixture("Domain_DnsRecord_List_FAILURE.json")). + Build(t) query := ListRecordQuery{ Domain: "www.example.com", } - _, err := client.ListRecords(context.Background(), query) + _, err := client.ListRecords(t.Context(), query) require.Error(t, err) } @@ -202,58 +251,10 @@ func TestClient_ListRecords_integration(t *testing.T) { Domain: "example.com", } - records, err := client.ListRecords(context.Background(), query) + records, err := client.ListRecords(t.Context(), query) require.NoError(t, err) for _, record := range records { fmt.Println(record) } } - -func setupTest(t *testing.T, path, filename string) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(path, testHandler(filename)) - - client := NewClient(testAPIKey, testPassword) - client.baseURL, _ = url.Parse(server.URL) - - return client -} - -func testHandler(filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - if req.FormValue("apiKey") != testAPIKey { - http.Error(rw, `{"transactid":"d46d812569acdb8b39c3933ec4351e79","status":"FAILURE","message":"Invalid API key and\/or Password","code":107002}`, http.StatusOK) - return - } - - if req.FormValue("password") != testPassword { - http.Error(rw, `{"transactid":"d46d812569acdb8b39c3933ec4351e79","status":"FAILURE","message":"Invalid API key and\/or Password","code":107002}`, http.StatusOK) - return - } - - file, err := os.Open(filename) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} diff --git a/providers/dns/internetbs/internal/fixtures/auth_error.json b/providers/dns/internetbs/internal/fixtures/auth_error.json new file mode 100644 index 000000000..a40a0ef5e --- /dev/null +++ b/providers/dns/internetbs/internal/fixtures/auth_error.json @@ -0,0 +1,6 @@ +{ + "transactid": "d46d812569acdb8b39c3933ec4351e79", + "status": "FAILURE", + "message": "Invalid API key and\/or Password", + "code": 107002 +} diff --git a/providers/dns/internetbs/internetbs.go b/providers/dns/internetbs/internetbs.go index 89b33eae3..e8cb868d2 100644 --- a/providers/dns/internetbs/internetbs.go +++ b/providers/dns/internetbs/internetbs.go @@ -8,8 +8,10 @@ import ( "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/internetbs/internal" ) @@ -26,6 +28,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -85,6 +89,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, diff --git a/providers/dns/internetbs/internetbs.toml b/providers/dns/internetbs/internetbs.toml index 054a1f6e9..f22850253 100644 --- a/providers/dns/internetbs/internetbs.toml +++ b/providers/dns/internetbs/internetbs.toml @@ -7,7 +7,7 @@ Since = "v4.5.0" Example = ''' INTERNET_BS_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx \ INTERNET_BS_PASSWORD=yyyyyyyyyyyyyyyyyyyyyyyyyy \ -lego --email you@example.com --dns internetbs -d '*.example.com' -d example.com run +lego --dns internetbs -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,10 +15,10 @@ lego --email you@example.com --dns internetbs -d '*.example.com' -d example.com INTERNET_BS_API_KEY = "API key" INTERNET_BS_PASSWORD = "API password" [Configuration.Additional] - INTERNET_BS_POLLING_INTERVAL = "Time between DNS propagation check" - INTERNET_BS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - INTERNET_BS_TTL = "The TTL of the TXT record used for the DNS challenge" - INTERNET_BS_HTTP_TIMEOUT = "API request timeout" + INTERNET_BS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + INTERNET_BS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + INTERNET_BS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" + INTERNET_BS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://internetbs.net/internet-bs-api.pdf" diff --git a/providers/dns/internetbs/internetbs_test.go b/providers/dns/internetbs/internetbs_test.go index ea328d506..be436d6e7 100644 --- a/providers/dns/internetbs/internetbs_test.go +++ b/providers/dns/internetbs/internetbs_test.go @@ -49,6 +49,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -121,6 +122,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -134,6 +136,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/inwx/inwx.go b/providers/dns/inwx/inwx.go index dc26362f9..0e79d71e0 100644 --- a/providers/dns/inwx/inwx.go +++ b/providers/dns/inwx/inwx.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" @@ -27,6 +28,8 @@ const ( EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Username string @@ -43,7 +46,7 @@ func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 300), // INWX has rather unstable propagation delays, thus using a larger default value - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 360*time.Second), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 6*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), Sandbox: env.GetOrDefaultBool(EnvSandbox, false), } @@ -94,14 +97,14 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - challengeInfo := dns01.GetChallengeInfo(domain, keyAuth) + info := dns01.GetChallengeInfo(domain, keyAuth) - authZone, err := dns01.FindZoneByFqdn(challengeInfo.EffectiveFQDN) + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("inwx: could not find zone for domain %q (%s): %w", domain, challengeInfo.EffectiveFQDN, err) + return fmt.Errorf("inwx: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) } - info, err := d.client.Account.Login() + login, err := d.client.Account.Login() if err != nil { return fmt.Errorf("inwx: %w", err) } @@ -113,27 +116,24 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { } }() - err = d.twoFactorAuth(info) + err = d.twoFactorAuth(login) if err != nil { return fmt.Errorf("inwx: %w", err) } request := &goinwx.NameserverRecordRequest{ Domain: dns01.UnFqdn(authZone), - Name: dns01.UnFqdn(challengeInfo.EffectiveFQDN), + Name: dns01.UnFqdn(info.EffectiveFQDN), Type: "TXT", - Content: challengeInfo.Value, + Content: info.Value, TTL: d.config.TTL, } _, err = d.client.Nameservers.CreateRecord(request) if err != nil { var er *goinwx.ErrorResponse - if errors.As(err, &er) { - if er.Message == "Object exists" { - return nil - } - return fmt.Errorf("inwx: %w", err) + if errors.As(err, &er) && er.Message == "Object exists" { + return nil } return fmt.Errorf("inwx: %w", err) @@ -144,14 +144,14 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - challengeInfo := dns01.GetChallengeInfo(domain, keyAuth) + info := dns01.GetChallengeInfo(domain, keyAuth) - authZone, err := dns01.FindZoneByFqdn(challengeInfo.EffectiveFQDN) + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("inwx: could not find zone for domain %q (%s): %w", domain, challengeInfo.EffectiveFQDN, err) + return fmt.Errorf("inwx: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) } - info, err := d.client.Account.Login() + login, err := d.client.Account.Login() if err != nil { return fmt.Errorf("inwx: %w", err) } @@ -163,29 +163,42 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } }() - err = d.twoFactorAuth(info) + err = d.twoFactorAuth(login) if err != nil { return fmt.Errorf("inwx: %w", err) } response, err := d.client.Nameservers.Info(&goinwx.NameserverInfoRequest{ Domain: dns01.UnFqdn(authZone), - Name: dns01.UnFqdn(challengeInfo.EffectiveFQDN), + Name: dns01.UnFqdn(info.EffectiveFQDN), Type: "TXT", }) if err != nil { return fmt.Errorf("inwx: %w", err) } - var lastErr error + var recordID string + for _, record := range response.Records { - err = d.client.Nameservers.DeleteRecord(record.ID) - if err != nil { - lastErr = fmt.Errorf("inwx: %w", err) + if record.Content != info.Value { + continue } + + recordID = record.ID + + break } - return lastErr + if recordID == "" { + return errors.New("inwx: TXT record not found") + } + + err = d.client.Nameservers.DeleteRecord(recordID) + if err != nil { + return fmt.Errorf("inwx: %w", err) + } + + return nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. diff --git a/providers/dns/inwx/inwx.toml b/providers/dns/inwx/inwx.toml index 1186dcf20..da4c6d959 100644 --- a/providers/dns/inwx/inwx.toml +++ b/providers/dns/inwx/inwx.toml @@ -7,13 +7,13 @@ Since = "v2.0.0" Example = ''' INWX_USERNAME=xxxxxxxxxx \ INWX_PASSWORD=yyyyyyyyyy \ -lego --email you@example.com --dns inwx -d '*.example.com' -d example.com run +lego --dns inwx -d '*.example.com' -d example.com run # 2FA INWX_USERNAME=xxxxxxxxxx \ INWX_PASSWORD=yyyyyyyyyy \ INWX_SHARED_SECRET=zzzzzzzzzz \ -lego --email you@example.com --dns inwx -d '*.example.com' -d example.com run +lego --dns inwx -d '*.example.com' -d example.com run ''' [Configuration] @@ -22,9 +22,9 @@ lego --email you@example.com --dns inwx -d '*.example.com' -d example.com run INWX_PASSWORD = "Password" [Configuration.Additional] INWX_SHARED_SECRET = "shared secret related to 2FA" - INWX_POLLING_INTERVAL = "Time between DNS propagation check" - INWX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation (default 360s)" - INWX_TTL = "The TTL of the TXT record used for the DNS challenge" + INWX_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + INWX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 360)" + INWX_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" INWX_SANDBOX = "Activate the sandbox (boolean)" [Links] diff --git a/providers/dns/inwx/inwx_test.go b/providers/dns/inwx/inwx_test.go index 39ce7d70e..47b12e228 100644 --- a/providers/dns/inwx/inwx_test.go +++ b/providers/dns/inwx/inwx_test.go @@ -62,6 +62,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -124,6 +125,7 @@ func TestLivePresentAndCleanup(t *testing.T) { } envTest.RestoreEnv() + envTest.Apply(map[string]string{ EnvSandbox: "true", EnvTTL: "3600", // In sandbox mode, the minimum allowed TTL is 3600 diff --git a/providers/dns/ionos/internal/client_test.go b/providers/dns/ionos/internal/client_test.go deleted file mode 100644 index 21a7a2675..000000000 --- a/providers/dns/ionos/internal/client_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package internal - -import ( - "context" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestClient_ListZones(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/zones", mockHandler(http.MethodGet, http.StatusOK, "list_zones.json")) - - zones, err := client.ListZones(context.Background()) - require.NoError(t, err) - - expected := []Zone{{ - ID: "11af3414-ebba-11e9-8df5-66fbe8a334b4", - Name: "test.com", - Type: "NATIVE", - }} - - assert.Equal(t, expected, zones) -} - -func TestClient_ListZones_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/zones", mockHandler(http.MethodGet, http.StatusUnauthorized, "list_zones_error.json")) - - zones, err := client.ListZones(context.Background()) - require.Error(t, err) - - assert.Nil(t, zones) - - var cErr *ClientError - assert.ErrorAs(t, err, &cErr) - assert.Equal(t, http.StatusUnauthorized, cErr.StatusCode) -} - -func TestClient_GetRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodGet, http.StatusOK, "get_records.json")) - - records, err := client.GetRecords(context.Background(), "azone01", nil) - require.NoError(t, err) - - expected := []Record{{ - ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4", - Name: "string", - Content: "string", - Type: "A", - }} - - assert.Equal(t, expected, records) -} - -func TestClient_GetRecords_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodGet, http.StatusUnauthorized, "get_records_error.json")) - - records, err := client.GetRecords(context.Background(), "azone01", nil) - require.Error(t, err) - - assert.Nil(t, records) - - var cErr *ClientError - assert.ErrorAs(t, err, &cErr) - assert.Equal(t, http.StatusUnauthorized, cErr.StatusCode) -} - -func TestClient_RemoveRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/zones/azone01/records/arecord01", mockHandler(http.MethodDelete, http.StatusOK, "")) - - err := client.RemoveRecord(context.Background(), "azone01", "arecord01") - require.NoError(t, err) -} - -func TestClient_RemoveRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/zones/azone01/records/arecord01", mockHandler(http.MethodDelete, http.StatusInternalServerError, "remove_record_error.json")) - - err := client.RemoveRecord(context.Background(), "azone01", "arecord01") - require.Error(t, err) - - var cErr *ClientError - assert.ErrorAs(t, err, &cErr) - assert.Equal(t, http.StatusInternalServerError, cErr.StatusCode) -} - -func TestClient_ReplaceRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodPatch, http.StatusOK, "")) - - records := []Record{{ - ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4", - Name: "string", - Content: "string", - Type: "A", - }} - - err := client.ReplaceRecords(context.Background(), "azone01", records) - require.NoError(t, err) -} - -func TestClient_ReplaceRecords_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodPatch, http.StatusBadRequest, "replace_records_error.json")) - - records := []Record{{ - ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4", - Name: "string", - Content: "string", - Type: "A", - }} - - err := client.ReplaceRecords(context.Background(), "azone01", records) - require.Error(t, err) - - var cErr *ClientError - assert.ErrorAs(t, err, &cErr) - assert.Equal(t, http.StatusBadRequest, cErr.StatusCode) -} - -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client, err := NewClient("secret") - require.NoError(t, err) - - client.BaseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func mockHandler(method string, statusCode int, filename string) func(http.ResponseWriter, *http.Request) { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - if filename == "" { - rw.WriteHeader(statusCode) - return - } - - file, err := os.Open(filepath.FromSlash(path.Join("./fixtures", filename))) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} diff --git a/providers/dns/ionos/ionos.go b/providers/dns/ionos/ionos.go index fc322c894..892370f5d 100644 --- a/providers/dns/ionos/ionos.go +++ b/providers/dns/ionos/ionos.go @@ -2,21 +2,17 @@ package ionos import ( - "context" "errors" "fmt" "net/http" - "strconv" - "strings" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" - "github.com/go-acme/lego/v4/providers/dns/ionos/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/ionos" ) -const minTTL = 300 - // Environment variables names. const ( envNamespace = "IONOS_" @@ -29,20 +25,18 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const minTTL = 300 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. -type Config struct { - APIKey string - PropagationTimeout time.Duration - PollingInterval time.Duration - TTL int - HTTPClient *http.Client -} +type Config = ionos.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - TTL: env.GetOrDefaultInt(EnvTTL, minTTL), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + TTL: env.GetOrDefaultInt(EnvTTL, ionos.MinTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 15*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), @@ -52,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. @@ -76,126 +69,36 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("ionos: the configuration of the DNS provider is nil") } - if config.APIKey == "" { - return nil, errors.New("ionos: credentials missing") - } - - if config.TTL < minTTL { - return nil, fmt.Errorf("ionos: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) - } - - client, err := internal.NewClient(config.APIKey) + provider, err := ionos.NewDNSProviderConfig(config, "") if err != nil { return nil, fmt.Errorf("ionos: %w", err) } - if config.HTTPClient != nil { - client.HTTPClient = config.HTTPClient - } - - return &DNSProvider{config: config, client: client}, nil + return &DNSProvider{prv: provider}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.config.PropagationTimeout, d.config.PollingInterval + return d.prv.Timeout() } // Present creates a TXT record using the specified parameters. -func (d *DNSProvider) Present(domain, _, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - ctx := context.Background() - - zones, err := d.client.ListZones(ctx) +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + err := d.prv.Present(domain, token, keyAuth) if err != nil { - return fmt.Errorf("ionos: failed to get zones: %w", err) - } - - name := dns01.UnFqdn(info.EffectiveFQDN) - - zone := findZone(zones, name) - if zone == nil { - return errors.New("ionos: no matching zone found for domain") - } - - filter := &internal.RecordsFilter{ - Suffix: name, - RecordType: "TXT", - } - - records, err := d.client.GetRecords(ctx, zone.ID, filter) - if err != nil { - return fmt.Errorf("ionos: failed to get records (zone=%s): %w", zone.ID, err) - } - - records = append(records, internal.Record{ - Name: name, - Content: info.Value, - TTL: d.config.TTL, - Type: "TXT", - }) - - err = d.client.ReplaceRecords(ctx, zone.ID, records) - if err != nil { - return fmt.Errorf("ionos: failed to create/update records (zone=%s): %w", zone.ID, err) + return fmt.Errorf("ionos: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. -func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - ctx := context.Background() - - zones, err := d.client.ListZones(ctx) +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { - return fmt.Errorf("ionos: failed to get zones: %w", err) + return fmt.Errorf("ionos: %w", err) } - name := dns01.UnFqdn(info.EffectiveFQDN) - - zone := findZone(zones, name) - if zone == nil { - return errors.New("ionos: no matching zone found for domain") - } - - filter := &internal.RecordsFilter{ - Suffix: name, - RecordType: "TXT", - } - - records, err := d.client.GetRecords(ctx, zone.ID, filter) - if err != nil { - return fmt.Errorf("ionos: failed to get records (zone=%s): %w", zone.ID, err) - } - - for _, record := range records { - if record.Name == name && record.Content == strconv.Quote(info.Value) { - err = d.client.RemoveRecord(ctx, zone.ID, record.ID) - if err != nil { - return fmt.Errorf("ionos: failed to remove record (zone=%s, record=%s): %w", zone.ID, record.ID, err) - } - return nil - } - } - - return fmt.Errorf("ionos: failed to remove record, record not found (zone=%s, domain=%s, fqdn=%s, value=%s)", zone.ID, domain, info.EffectiveFQDN, info.Value) -} - -func findZone(zones []internal.Zone, domain string) *internal.Zone { - var result *internal.Zone - - for _, zone := range zones { - if zone.Name != "" && strings.HasSuffix(domain, zone.Name) { - if result == nil || len(zone.Name) > len(result.Name) { - result = &zone - } - } - } - - return result + return nil } diff --git a/providers/dns/ionos/ionos.toml b/providers/dns/ionos/ionos.toml index e9bfd7319..a2c9518fb 100644 --- a/providers/dns/ionos/ionos.toml +++ b/providers/dns/ionos/ionos.toml @@ -6,17 +6,17 @@ Since = "v4.2.0" Example = ''' IONOS_API_KEY=xxxxxxxx \ -lego --email you@example.com --dns ionos -d '*.example.com' -d example.com run +lego --dns ionos -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] IONOS_API_KEY = "API key `.` https://developer.hosting.ionos.com/docs/getstarted" [Configuration.Additional] - IONOS_POLLING_INTERVAL = "Time between DNS propagation check" - IONOS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - IONOS_TTL = "The TTL of the TXT record used for the DNS challenge" - IONOS_HTTP_TIMEOUT = "API request timeout" + IONOS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + IONOS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 900)" + IONOS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + IONOS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developer.hosting.ionos.com/docs/dns" diff --git a/providers/dns/ionos/ionos_test.go b/providers/dns/ionos/ionos_test.go index 5aef6ad14..39dc0c511 100644 --- a/providers/dns/ionos/ionos_test.go +++ b/providers/dns/ionos/ionos_test.go @@ -9,9 +9,7 @@ import ( const envDomain = envNamespace + "DOMAIN" -var envTest = tester.NewEnvTest( - EnvAPIKey). - WithDomain(envDomain) +var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { @@ -37,6 +35,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -46,8 +45,7 @@ func TestNewDNSProvider(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - require.NotNil(t, p.config) - require.NotNil(t, p.client) + require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -91,8 +89,7 @@ func TestNewDNSProviderConfig(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - require.NotNil(t, p.config) - require.NotNil(t, p.client) + require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -106,6 +103,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -119,6 +117,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/ionoscloud/internal/client.go b/providers/dns/ionoscloud/internal/client.go new file mode 100644 index 000000000..5b7d3a0fc --- /dev/null +++ b/providers/dns/ionoscloud/internal/client.go @@ -0,0 +1,172 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://dns.de-fra.ionos.com" + +const authorizationHeader = "Authorization" + +// Client the Ionos Cloud API client. +type Client struct { + apiKey string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(apiKey string) (*Client, error) { + if apiKey == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + apiKey: apiKey, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// RetrieveZones returns a list of the DNS zones. +// https://api.ionos.com/docs/dns/v1/#tag/Zones/operation/zonesGet +func (c *Client) RetrieveZones(ctx context.Context, zoneName string) ([]Zone, error) { + endpoint := c.BaseURL.JoinPath("zones") + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + query := req.URL.Query() + query.Add("filter.zoneName", zoneName) + req.URL.RawQuery = query.Encode() + + result := ZonesResponse{} + + if err := c.do(req, &result); err != nil { + return nil, err + } + + return result.Items, nil +} + +// CreateRecord creates a new record for the DNS zone. +// https://api.ionos.com/docs/dns/v1/#tag/Records/operation/zonesRecordsPost +func (c *Client) CreateRecord(ctx context.Context, zoneID string, record RecordProperties) (*RecordResponse, error) { + endpoint := c.BaseURL.JoinPath("zones", zoneID, "records") + + payload := map[string]RecordProperties{ + "properties": record, + } + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload) + if err != nil { + return nil, err + } + + result := &RecordResponse{} + + if err := c.do(req, result); err != nil { + return nil, err + } + + return result, nil +} + +// DeleteRecord deletes a specified record from the DNS zone. +// https://api.ionos.com/docs/dns/v1/#tag/Records/operation/zonesRecordsDelete +func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error { + endpoint := c.BaseURL.JoinPath("zones", zoneID, "records", recordID) + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) do(req *http.Request, result any) error { + useragent.SetHeader(req.Header) + + req.Header.Set(authorizationHeader, "Bearer "+c.apiKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/ionoscloud/internal/client_test.go b/providers/dns/ionoscloud/internal/client_test.go new file mode 100644 index 000000000..dc478cc64 --- /dev/null +++ b/providers/dns/ionoscloud/internal/client_test.go @@ -0,0 +1,134 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + WithAuthorization("Bearer secret"), + ) +} + +func TestClient_RetrieveZones(t *testing.T) { + client := mockBuilder(). + Route("GET /zones", + servermock.ResponseFromFixture("zones.json"), + servermock.CheckQueryParameter().Strict(). + With("filter.zoneName", "example.com")). + Build(t) + + zones, err := client.RetrieveZones(t.Context(), "example.com") + require.NoError(t, err) + + expected := []Zone{{ + ID: "e74d0d15-f567-4b7b-9069-26ee1f93bae3", + Type: "zone", + Metadata: ZoneMetadata{ + CreatedDate: time.Date(2022, time.August, 21, 15, 52, 53, 0, time.UTC), + CreatedBy: "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", + CreatedByUserID: "87f9a82e-b28d-49ed-9d04-fba2c0459cd3", + LastModifiedDate: time.Date(2022, time.August, 21, 15, 52, 53, 0, time.UTC), + LastModifiedBy: "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", + LastModifiedByUserID: "63cef532-26fe-4a64-a4e0-de7c8a506c90", + ResourceURN: "ionos::::", + State: "PROVISIONING", + Nameservers: []string{"ns-ic.ui-dns.com", "ns-ic.ui-dns.de", "ns-ic.ui-dns.org", "ns-ic.ui-dns.biz"}, + }, + Properties: ZoneProperties{ + ZoneName: "example.com", + Description: "The hosted zone is used for example.com", + Enabled: true, + }, + }} + + assert.Equal(t, expected, zones) +} + +func TestClient_RetrieveZones_error(t *testing.T) { + client := mockBuilder(). + Route("GET /zones", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + _, err := client.RetrieveZones(t.Context(), "example.com") + require.EqualError(t, err, "401: paas-auth-1: Unauthorized, wrong or no api key provided to process this request") +} + +func TestClient_CreateRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /zones/abc/records", + servermock.ResponseFromFixture("create_record.json"), + servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). + Build(t) + + record := RecordProperties{ + Name: "_acme-challenge", + Type: "TXT", + Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 120, + } + + result, err := client.CreateRecord(t.Context(), "abc", record) + require.NoError(t, err) + + expected := &RecordResponse{ + ID: "90d81ac0-3a30-44d4-95a5-12959effa6ee", + Type: "record", + Metadata: RecordMetadata{ + CreatedDate: time.Date(2022, time.August, 21, 15, 52, 53, 0, time.UTC), + CreatedBy: "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", + CreatedByUserID: "87f9a82e-b28d-49ed-9d04-fba2c0459cd3", + LastModifiedDate: time.Date(2022, time.August, 21, 15, 52, 53, 0, time.UTC), + LastModifiedBy: "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", + LastModifiedByUserID: "63cef532-26fe-4a64-a4e0-de7c8a506c90", + ResourceURN: "ionos::::", + State: "PROVISIONING", + Fqdn: "app.example.com", + ZoneID: "a363f30c-4c0c-4552-9a07-298d87f219bf", + }, + Properties: RecordProperties{ + Name: "app", + Type: "A", + Content: "1.2.3.4", + TTL: 3600, + Priority: 3600, + Enabled: true, + }, + } + + assert.Equal(t, expected, result) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /zones/abc/records/def", + servermock.Noop(). + WithStatusCode(http.StatusAccepted)). + Build(t) + + err := client.DeleteRecord(t.Context(), "abc", "def") + require.NoError(t, err) +} diff --git a/providers/dns/ionoscloud/internal/fixtures/create_record-request.json b/providers/dns/ionoscloud/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..d4f52bba8 --- /dev/null +++ b/providers/dns/ionoscloud/internal/fixtures/create_record-request.json @@ -0,0 +1,8 @@ +{ + "properties": { + "name": "_acme-challenge", + "type": "TXT", + "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 120 + } +} diff --git a/providers/dns/ionoscloud/internal/fixtures/create_record.json b/providers/dns/ionoscloud/internal/fixtures/create_record.json new file mode 100644 index 000000000..d3094c3b2 --- /dev/null +++ b/providers/dns/ionoscloud/internal/fixtures/create_record.json @@ -0,0 +1,25 @@ +{ + "id": "90d81ac0-3a30-44d4-95a5-12959effa6ee", + "type": "record", + "href": "", + "metadata": { + "createdDate": "2022-08-21T15:52:53Z", + "createdBy": "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", + "createdByUserId": "87f9a82e-b28d-49ed-9d04-fba2c0459cd3", + "lastModifiedDate": "2022-08-21T15:52:53Z", + "lastModifiedBy": "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", + "lastModifiedByUserId": "63cef532-26fe-4a64-a4e0-de7c8a506c90", + "resourceURN": "ionos::::", + "state": "PROVISIONING", + "fqdn": "app.example.com", + "zoneId": "a363f30c-4c0c-4552-9a07-298d87f219bf" + }, + "properties": { + "name": "app", + "type": "A", + "content": "1.2.3.4", + "ttl": 3600, + "priority": 3600, + "enabled": true + } +} diff --git a/providers/dns/ionoscloud/internal/fixtures/error.json b/providers/dns/ionoscloud/internal/fixtures/error.json new file mode 100644 index 000000000..bed0e5efb --- /dev/null +++ b/providers/dns/ionoscloud/internal/fixtures/error.json @@ -0,0 +1,9 @@ +{ + "httpStatus": 401, + "messages": [ + { + "errorCode": "paas-auth-1", + "message": "Unauthorized, wrong or no api key provided to process this request" + } + ] +} diff --git a/providers/dns/ionoscloud/internal/fixtures/zones.json b/providers/dns/ionoscloud/internal/fixtures/zones.json new file mode 100644 index 000000000..c9c2c62f9 --- /dev/null +++ b/providers/dns/ionoscloud/internal/fixtures/zones.json @@ -0,0 +1,40 @@ +{ + "id": "e74d0d15-f567-4b7b-9069-26ee1f93bae3", + "type": "collection", + "href": "", + "offset": 0, + "limit": 1000, + "_links": { + "prev": "http://PREVIOUS-PAGE-URI", + "self": "http://THIS-PAGE-URI", + "next": "http://NEXT-PAGE-URI" + }, + "items": [ + { + "id": "e74d0d15-f567-4b7b-9069-26ee1f93bae3", + "type": "zone", + "href": "", + "metadata": { + "createdDate": "2022-08-21T15:52:53Z", + "createdBy": "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", + "createdByUserId": "87f9a82e-b28d-49ed-9d04-fba2c0459cd3", + "lastModifiedDate": "2022-08-21T15:52:53Z", + "lastModifiedBy": "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", + "lastModifiedByUserId": "63cef532-26fe-4a64-a4e0-de7c8a506c90", + "resourceURN": "ionos::::", + "state": "PROVISIONING", + "nameservers": [ + "ns-ic.ui-dns.com", + "ns-ic.ui-dns.de", + "ns-ic.ui-dns.org", + "ns-ic.ui-dns.biz" + ] + }, + "properties": { + "zoneName": "example.com", + "description": "The hosted zone is used for example.com", + "enabled": true + } + } + ] +} diff --git a/providers/dns/ionoscloud/internal/types.go b/providers/dns/ionoscloud/internal/types.go new file mode 100644 index 000000000..49348f4d1 --- /dev/null +++ b/providers/dns/ionoscloud/internal/types.go @@ -0,0 +1,97 @@ +package internal + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +type APIError struct { + HTTPStatus int `json:"httpStatus"` + Messages []ErrorMessage `json:"messages"` +} + +func (a *APIError) Error() string { + var msg strings.Builder + + msg.WriteString(strconv.Itoa(a.HTTPStatus)) + + for _, m := range a.Messages { + msg.WriteString(": ") + msg.WriteString(m.String()) + } + + return msg.String() +} + +type ErrorMessage struct { + ErrorCode string `json:"errorCode"` + Message string `json:"message"` +} + +func (e ErrorMessage) String() string { + return fmt.Sprintf("%s: %s", e.ErrorCode, e.Message) +} + +type ZonesResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Items []Zone `json:"items"` +} + +type Zone struct { + ID string `json:"id"` + Type string `json:"type"` + Metadata ZoneMetadata `json:"metadata"` + Properties ZoneProperties `json:"properties"` +} + +type ZoneMetadata struct { + CreatedDate time.Time `json:"createdDate"` + CreatedBy string `json:"createdBy"` + CreatedByUserID string `json:"createdByUserId"` + LastModifiedDate time.Time `json:"lastModifiedDate"` + LastModifiedBy string `json:"lastModifiedBy"` + LastModifiedByUserID string `json:"lastModifiedByUserId"` + ResourceURN string `json:"resourceURN"` + State string `json:"state"` + Nameservers []string `json:"nameservers"` +} + +type ZoneProperties struct { + ZoneName string `json:"zoneName"` + Description string `json:"description"` + Enabled bool `json:"enabled"` +} + +type RecordResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Metadata RecordMetadata `json:"metadata"` + Properties RecordProperties `json:"properties"` +} + +type RecordMetadata struct { + CreatedDate time.Time `json:"createdDate"` + CreatedBy string `json:"createdBy"` + CreatedByUserID string `json:"createdByUserId"` + LastModifiedDate time.Time `json:"lastModifiedDate"` + LastModifiedBy string `json:"lastModifiedBy"` + LastModifiedByUserID string `json:"lastModifiedByUserId"` + ResourceURN string `json:"resourceURN"` + State string `json:"state"` + Fqdn string `json:"fqdn"` + ZoneID string `json:"zoneId"` +} + +type RecordProperties struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Content string `json:"content,omitempty"` + TTL int `json:"ttl,omitempty"` + Priority int `json:"priority,omitempty"` + Enabled bool `json:"enabled,omitempty"` +} diff --git a/providers/dns/ionoscloud/ionoscloud.go b/providers/dns/ionoscloud/ionoscloud.go new file mode 100644 index 000000000..0c33fba9f --- /dev/null +++ b/providers/dns/ionoscloud/ionoscloud.go @@ -0,0 +1,184 @@ +// Package ionoscloud implements a DNS provider for solving the DNS-01 challenge using Ionos Cloud. +package ionoscloud + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/ionoscloud/internal" +) + +// Environment variables names. +const ( + envNamespace = "IONOSCLOUD_" + + EnvAPIToken = envNamespace + "API_TOKEN" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIToken string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + zoneIDs map[string]string + recordIDs map[string]string + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for Ionos Cloud. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIToken) + if err != nil { + return nil, fmt.Errorf("ionoscloud: %w", err) + } + + config := NewDefaultConfig() + config.APIToken = values[EnvAPIToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Ionos Cloud. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("ionoscloud: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.APIToken) + if err != nil { + return nil, fmt.Errorf("ionoscloud: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + zoneIDs: make(map[string]string), + recordIDs: make(map[string]string), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("ionoscloud: could not find zone for domain %q: %w", domain, err) + } + + zones, err := d.client.RetrieveZones(ctx, dns01.UnFqdn(authZone)) + if err != nil { + return fmt.Errorf("ionoscloud: retrieve zones: %w", err) + } + + if len(zones) != 1 { + return fmt.Errorf("ionoscloud: zone ID not found for domain %q", domain) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("ionoscloud: %w", err) + } + + zoneID := zones[0].ID + + request := internal.RecordProperties{ + Name: subDomain, + Type: "TXT", + Content: info.Value, + TTL: d.config.TTL, + } + + record, err := d.client.CreateRecord(ctx, zoneID, request) + if err != nil { + return fmt.Errorf("ionoscloud: create record: %w", err) + } + + d.recordIDsMu.Lock() + d.zoneIDs[token] = zoneID + d.recordIDs[token] = record.ID + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + d.recordIDsMu.Lock() + zoneID, ok := d.zoneIDs[token] + d.recordIDsMu.Unlock() + + if !ok { + return fmt.Errorf("ionoscloud: unknown zone ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + + if !ok { + return fmt.Errorf("ionoscloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + err := d.client.DeleteRecord(context.Background(), zoneID, recordID) + if err != nil { + return fmt.Errorf("ionoscloud: delete record: %w", err) + } + + d.recordIDsMu.Lock() + delete(d.zoneIDs, token) + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/ionoscloud/ionoscloud.toml b/providers/dns/ionoscloud/ionoscloud.toml new file mode 100644 index 000000000..6e1d080e4 --- /dev/null +++ b/providers/dns/ionoscloud/ionoscloud.toml @@ -0,0 +1,22 @@ +Name = "Ionos Cloud" +Description = '''''' +URL = "https://cloud.ionos.de/network/cloud-dns" +Code = "ionoscloud" +Since = "v4.30.0" + +Example = ''' +IONOSCLOUD_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns ionoscloud -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + IONOSCLOUD_API_TOKEN = "API token" + [Configuration.Additional] + IONOSCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + IONOSCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + IONOSCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + IONOSCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://api.ionos.com/docs/dns/v1/" diff --git a/providers/dns/ionoscloud/ionoscloud_test.go b/providers/dns/ionoscloud/ionoscloud_test.go new file mode 100644 index 000000000..8282e08fc --- /dev/null +++ b/providers/dns/ionoscloud/ionoscloud_test.go @@ -0,0 +1,173 @@ +package ionoscloud + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIToken).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIToken: "secret", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "ionoscloud: some credentials information are missing: IONOSCLOUD_API_TOKEN", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiToken string + expected string + }{ + { + desc: "success", + apiToken: "secret", + }, + { + desc: "missing credentials", + expected: "ionoscloud: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIToken = test.apiToken + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIToken = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + WithAuthorization("Bearer secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("GET /zones", + servermock.ResponseFromInternal("zones.json"), + servermock.CheckQueryParameter().Strict(). + With("filter.zoneName", "example.com")). + Route("POST /zones/e74d0d15-f567-4b7b-9069-26ee1f93bae3/records", + servermock.ResponseFromInternal("create_record.json"), + servermock.CheckRequestJSONBodyFromInternal("create_record-request.json")). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("DELETE /zones/e74d0d15-f567-4b7b-9069-26ee1f93bae3/records/90d81ac0-3a30-44d4-95a5-12959effa6ee", + servermock.Noop(). + WithStatusCode(http.StatusAccepted)). + Build(t) + + token := "abc" + + provider.zoneIDs[token] = "e74d0d15-f567-4b7b-9069-26ee1f93bae3" + provider.recordIDs[token] = "90d81ac0-3a30-44d4-95a5-12959effa6ee" + + err := provider.CleanUp("example.com", token, "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/ipv64/internal/client.go b/providers/dns/ipv64/internal/client.go index fbb871aa3..0dfd94374 100644 --- a/providers/dns/ipv64/internal/client.go +++ b/providers/dns/ipv64/internal/client.go @@ -34,7 +34,7 @@ func NewClient(hc *http.Client) *Client { } } -func (c Client) GetDomains(ctx context.Context) (*Domains, error) { +func (c *Client) GetDomains(ctx context.Context) (*Domains, error) { endpoint := c.baseURL.JoinPath("api") query := endpoint.Query() @@ -56,7 +56,7 @@ func (c Client) GetDomains(ctx context.Context) (*Domains, error) { return results, nil } -func (c Client) AddRecord(ctx context.Context, domain, prefix, recordType, content string) error { +func (c *Client) AddRecord(ctx context.Context, domain, prefix, recordType, content string) error { endpoint := c.baseURL.JoinPath("api") data := make(url.Values) @@ -73,7 +73,7 @@ func (c Client) AddRecord(ctx context.Context, domain, prefix, recordType, conte return c.do(req, nil) } -func (c Client) DeleteRecord(ctx context.Context, domain, prefix, recordType, content string) error { +func (c *Client) DeleteRecord(ctx context.Context, domain, prefix, recordType, content string) error { endpoint := c.baseURL.JoinPath("api") data := make(url.Values) @@ -90,7 +90,7 @@ func (c Client) DeleteRecord(ctx context.Context, domain, prefix, recordType, co return c.do(req, nil) } -func (c Client) do(req *http.Request, result any) error { +func (c *Client) do(req *http.Request, result any) error { if req.Method != http.MethodGet { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } @@ -131,6 +131,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errAPI := &APIError{} + err := json.Unmarshal(raw, errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/ipv64/internal/client_test.go b/providers/dns/ipv64/internal/client_test.go index 1966f9f68..ba5ede9fc 100644 --- a/providers/dns/ipv64/internal/client_test.go +++ b/providers/dns/ipv64/internal/client_test.go @@ -1,69 +1,35 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const testAPIKey = "secret" -func setupTest(t *testing.T, handler http.HandlerFunc) *Client { - t.Helper() - - server := httptest.NewServer(handler) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient(OAuthStaticAccessToken(server.Client(), testAPIKey)) client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) - return client -} - -func testHandler(method, filename string, statusCode int) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Bearer "+testAPIKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } + return client, nil } func TestClient_GetDomains(t *testing.T) { - client := setupTest(t, testHandler(http.MethodGet, "get_domains.json", http.StatusOK)) + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /api", + servermock.ResponseFromFixture("get_domains.json"), + servermock.CheckQueryParameter().Strict(). + With("get_domains", "")). + Build(t) - domains, err := client.GetDomains(context.Background()) + domains, err := client.GetDomains(t.Context()) require.NoError(t, err) expected := &Domains{ @@ -112,38 +78,67 @@ func TestClient_GetDomains(t *testing.T) { } func TestClient_GetDomains_error(t *testing.T) { - client := setupTest(t, testHandler(http.MethodGet, "error.json", http.StatusUnauthorized)) + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /api", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - domains, err := client.GetDomains(context.Background()) + domains, err := client.GetDomains(t.Context()) require.Error(t, err) require.Nil(t, domains) } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, testHandler(http.MethodPost, "add_record.json", http.StatusCreated)) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithContentTypeFromURLEncoded()). + Route("POST /api", + servermock.ResponseFromFixture("add_record.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckForm().Strict(). + With("add_record", "lego.ipv64.net"). + With("content", "value"). + With("praefix", "_acme-challenge"). + With("type", "TXT"), + ). + Build(t) - err := client.AddRecord(context.Background(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") + err := client.AddRecord(t.Context(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, testHandler(http.MethodPost, "add_record-error.json", http.StatusBadRequest)) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /api", + servermock.ResponseFromFixture("add_record-error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) - err := client.AddRecord(context.Background(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") + err := client.AddRecord(t.Context(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") require.Error(t, err) } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, testHandler(http.MethodDelete, "del_record.json", http.StatusAccepted)) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithContentTypeFromURLEncoded()). + Route("DELETE /api", + // the query parameters can be checked because the Go server ignores the body of a DELETE request. + servermock.ResponseFromFixture("del_record.json"). + WithStatusCode(http.StatusAccepted)). + Build(t) - err := client.DeleteRecord(context.Background(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") + err := client.DeleteRecord(t.Context(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, testHandler(http.MethodDelete, "del_record-error.json", http.StatusBadRequest)) + client := servermock.NewBuilder[*Client](setupClient). + Route("DELETE /api", + servermock.ResponseFromFixture("del_record-error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) - err := client.DeleteRecord(context.Background(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") + err := client.DeleteRecord(t.Context(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") require.Error(t, err) } diff --git a/providers/dns/ipv64/internal/types.go b/providers/dns/ipv64/internal/types.go index e9e357ecc..6ef31a3cc 100644 --- a/providers/dns/ipv64/internal/types.go +++ b/providers/dns/ipv64/internal/types.go @@ -11,6 +11,7 @@ type APIResponse struct { type APIError struct { APIResponse + AddRecordMessage string `json:"add_record"` DelRecordMessage string `json:"del_record"` AddDomainMessage string `json:"add_domain"` @@ -41,6 +42,7 @@ func (a APIError) Error() string { type Domains struct { APIResponse + APICall string `json:"add_domain"` Subdomains map[string]Subdomain `json:"subdomains"` } diff --git a/providers/dns/ipv64/ipv64.go b/providers/dns/ipv64/ipv64.go index 578614bce..078fe5ca1 100644 --- a/providers/dns/ipv64/ipv64.go +++ b/providers/dns/ipv64/ipv64.go @@ -9,8 +9,10 @@ import ( "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/ipv64/internal" "github.com/miekg/dns" ) @@ -24,9 +26,10 @@ const ( EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" - EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" // Deprecated: unused, will be removed in v5. ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -83,6 +86,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } diff --git a/providers/dns/ipv64/ipv64.toml b/providers/dns/ipv64/ipv64.toml index ece506c34..aa1720c9e 100644 --- a/providers/dns/ipv64/ipv64.toml +++ b/providers/dns/ipv64/ipv64.toml @@ -6,17 +6,16 @@ Since = "v4.13.0" Example = ''' IPV64_API_KEY=xxxxxx \ -lego --email you@example.com --dns ipv64 -d '*.example.com' -d example.com run +lego --dns ipv64 -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] IPV64_API_KEY = "Account API Key" [Configuration.Additional] - IPV64_POLLING_INTERVAL = "Time between DNS propagation check" - IPV64_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - IPV64_TTL = "The TTL of the TXT record used for the DNS challenge" - IPV64_HTTP_TIMEOUT = "API request timeout" + IPV64_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + IPV64_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + IPV64_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://ipv64.net/dyndns_updater_api" diff --git a/providers/dns/ipv64/ipv64_test.go b/providers/dns/ipv64/ipv64_test.go index b3fe142e9..6dc7d1cfc 100644 --- a/providers/dns/ipv64/ipv64_test.go +++ b/providers/dns/ipv64/ipv64_test.go @@ -114,6 +114,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -171,6 +172,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -184,6 +186,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/ispconfig/internal/client.go b/providers/dns/ispconfig/internal/client.go new file mode 100644 index 000000000..9280fdec1 --- /dev/null +++ b/providers/dns/ispconfig/internal/client.go @@ -0,0 +1,318 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +type Client struct { + serverURL string + HTTPClient *http.Client +} + +func NewClient(serverURL string) (*Client, error) { + _, err := url.Parse(serverURL) + if err != nil { + return nil, fmt.Errorf("server URL: %w", err) + } + + return &Client{ + serverURL: serverURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) Login(ctx context.Context, username, password string) (string, error) { + payload := LoginRequest{ + Username: username, + Password: password, + ClientLogin: false, + } + + endpoint, err := url.Parse(c.serverURL) + if err != nil { + return "", err + } + + endpoint.RawQuery = "login" + + req, err := newJSONRequest(ctx, endpoint, payload) + if err != nil { + return "", err + } + + var response APIResponse + + err = c.do(req, &response) + if err != nil { + return "", err + } + + return extractResponse[string](response) +} + +func (c *Client) GetClientID(ctx context.Context, sessionID, sysUserID string) (int, error) { + payload := ClientIDRequest{ + SessionID: sessionID, + SysUserID: sysUserID, + } + + endpoint, err := url.Parse(c.serverURL) + if err != nil { + return 0, err + } + + endpoint.RawQuery = "client_get_id" + + req, err := newJSONRequest(ctx, endpoint, payload) + if err != nil { + return 0, err + } + + var response APIResponse + + err = c.do(req, &response) + if err != nil { + return 0, err + } + + return extractResponse[int](response) +} + +// GetZoneID returns the zone ID for the given name. +func (c *Client) GetZoneID(ctx context.Context, sessionID, name string) (int, error) { + payload := map[string]any{ + "session_id": sessionID, + "origin": name, + } + + endpoint, err := url.Parse(c.serverURL) + if err != nil { + return 0, err + } + + endpoint.RawQuery = "dns_zone_get_id" + + req, err := newJSONRequest(ctx, endpoint, payload) + if err != nil { + return 0, err + } + + var response APIResponse + + err = c.do(req, &response) + if err != nil { + return 0, err + } + + return extractResponse[int](response) +} + +// GetZone returns the zone information for the zone ID. +func (c *Client) GetZone(ctx context.Context, sessionID, zoneID string) (*Zone, error) { + payload := map[string]any{ + "session_id": sessionID, + "primary_id": zoneID, + } + + endpoint, err := url.Parse(c.serverURL) + if err != nil { + return nil, err + } + + endpoint.RawQuery = "dns_zone_get" + + req, err := newJSONRequest(ctx, endpoint, payload) + if err != nil { + return nil, err + } + + var response APIResponse + + err = c.do(req, &response) + if err != nil { + return nil, err + } + + return extractResponse[*Zone](response) +} + +// GetTXT returns the TXT record for the given name. +// `name` must be a fully qualified domain name, e.g. "example.com.". +func (c *Client) GetTXT(ctx context.Context, sessionID, name string) (*Record, error) { + payload := GetTXTRequest{ + SessionID: sessionID, + PrimaryID: struct { + Name string `json:"name"` + Type string `json:"type"` + }{ + Name: name, + Type: "txt", + }, + } + + endpoint, err := url.Parse(c.serverURL) + if err != nil { + return nil, err + } + + endpoint.RawQuery = "dns_txt_get" + + req, err := newJSONRequest(ctx, endpoint, payload) + if err != nil { + return nil, err + } + + var response APIResponse + + err = c.do(req, &response) + if err != nil { + return nil, err + } + + return extractResponse[*Record](response) +} + +// AddTXT adds a TXT record. +// It returns the ID of the newly created record. +func (c *Client) AddTXT(ctx context.Context, sessionID, clientID string, params RecordParams) (string, error) { + payload := AddTXTRequest{ + SessionID: sessionID, + ClientID: clientID, + Params: ¶ms, + UpdateSerial: true, + } + + endpoint, err := url.Parse(c.serverURL) + if err != nil { + return "", err + } + + endpoint.RawQuery = "dns_txt_add" + + req, err := newJSONRequest(ctx, endpoint, payload) + if err != nil { + return "", err + } + + var response APIResponse + + err = c.do(req, &response) + if err != nil { + return "", err + } + + return extractResponse[string](response) +} + +// DeleteTXT deletes a TXT record. +// It returns the number of deleted records. +func (c *Client) DeleteTXT(ctx context.Context, sessionID, recordID string) (int, error) { + payload := DeleteTXTRequest{ + SessionID: sessionID, + PrimaryID: recordID, + UpdateSerial: true, + } + + endpoint, err := url.Parse(c.serverURL) + if err != nil { + return 0, err + } + + endpoint.RawQuery = "dns_txt_delete" + + req, err := newJSONRequest(ctx, endpoint, payload) + if err != nil { + return 0, err + } + + var response APIResponse + + err = c.do(req, &response) + if err != nil { + return 0, err + } + + return extractResponse[int](response) +} + +func (c *Client) do(req *http.Request, result any) error { + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + raw, _ := io.ReadAll(resp.Body) + + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func extractResponse[T any](response APIResponse) (T, error) { + if response.Code != "ok" { + var zero T + + return zero, &APIError{APIResponse: response} + } + + var result T + + err := json.Unmarshal(response.Response, &result) + if err != nil { + var zero T + return zero, fmt.Errorf("unable to unmarshal response: %s, %w", string(response.Response), err) + } + + return result, nil +} diff --git a/providers/dns/ispconfig/internal/client_test.go b/providers/dns/ispconfig/internal/client_test.go new file mode 100644 index 000000000..a4db3d5f7 --- /dev/null +++ b/providers/dns/ispconfig/internal/client_test.go @@ -0,0 +1,175 @@ +package internal + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL) + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + + return client, nil + }) +} + +func TestClient_Login(t *testing.T) { + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture("login.json"), + servermock.CheckRequestJSONBodyFromFixture("login-request.json"), + servermock.CheckQueryParameter().Strict(). + With("login", ""), + ). + Build(t) + + sessionID, err := client.Login(t.Context(), "user", "secret") + require.NoError(t, err) + + assert.Equal(t, "abc", sessionID) +} + +func TestClient_Login_error(t *testing.T) { + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture("error.json"), + ). + Build(t) + + _, err := client.Login(t.Context(), "user", "secret") + require.EqualError(t, err, `code: remote_fault, message: The login failed. Username or password wrong., response: false`) +} + +func TestClient_GetClientID(t *testing.T) { + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture("client_get_id.json"), + servermock.CheckRequestJSONBodyFromFixture("client_get_id-request.json"), + servermock.CheckQueryParameter().Strict(). + With("client_get_id", ""), + ). + Build(t) + + id, err := client.GetClientID(t.Context(), "sessionA", "sysA") + require.NoError(t, err) + + assert.Equal(t, 123, id) +} + +func TestClient_GetZoneID(t *testing.T) { + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture("dns_zone_get_id.json"), + servermock.CheckRequestJSONBodyFromFixture("dns_zone_get_id-request.json"), + servermock.CheckQueryParameter().Strict(). + With("dns_zone_get_id", ""), + ). + Build(t) + + zoneID, err := client.GetZoneID(t.Context(), "sessionA", "example.com") + require.NoError(t, err) + + assert.Equal(t, 123, zoneID) +} + +func TestClient_GetZone(t *testing.T) { + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture("dns_zone_get.json"), + servermock.CheckRequestJSONBodyFromFixture("dns_zone_get-request.json"), + servermock.CheckQueryParameter().Strict(). + With("dns_zone_get", ""), + ). + Build(t) + + zone, err := client.GetZone(t.Context(), "sessionA", "example.com.") + require.NoError(t, err) + + expected := &Zone{ + ID: "456", + ServerID: "123", + SysUserID: "789", + SysGroupID: "2", + Origin: "example.com.", + Serial: "2025102902", + Active: "Y", + } + + assert.Equal(t, expected, zone) +} + +func TestClient_GetTXT(t *testing.T) { + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture("dns_txt_get.json"), + servermock.CheckRequestJSONBodyFromFixture("dns_txt_get-request.json"), + servermock.CheckQueryParameter().Strict(). + With("dns_txt_get", ""), + ). + Build(t) + + record, err := client.GetTXT(t.Context(), "sessionA", "example.com.") + require.NoError(t, err) + + expected := &Record{ID: 123} + + assert.Equal(t, expected, record) +} + +func TestClient_AddTXT(t *testing.T) { + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture("dns_txt_add.json"), + servermock.CheckRequestJSONBodyFromFixture("dns_txt_add-request.json"), + servermock.CheckQueryParameter().Strict(). + With("dns_txt_add", ""), + ). + Build(t) + + now := time.Date(2025, 12, 25, 1, 1, 1, 0, time.UTC) + + params := RecordParams{ + ServerID: "serverA", + Zone: "example.com.", + Name: "foo.example.com.", + Type: "txt", + Data: "txtTXTtxt", + Aux: "0", + TTL: "3600", + Active: "y", + Stamp: now.Format("2006-01-02 15:04:05"), + UpdateSerial: true, + } + + recordID, err := client.AddTXT(t.Context(), "sessionA", "clientA", params) + require.NoError(t, err) + + assert.Equal(t, "123", recordID) +} + +func TestClient_DeleteTXT(t *testing.T) { + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture("dns_txt_delete.json"), + servermock.CheckRequestJSONBodyFromFixture("dns_txt_delete-request.json"), + servermock.CheckQueryParameter().Strict(). + With("dns_txt_delete", ""), + ). + Build(t) + + count, err := client.DeleteTXT(t.Context(), "sessionA", "123") + require.NoError(t, err) + + assert.Equal(t, 1, count) +} diff --git a/providers/dns/ispconfig/internal/fixtures/client_get_id-request.json b/providers/dns/ispconfig/internal/fixtures/client_get_id-request.json new file mode 100644 index 000000000..ba573f824 --- /dev/null +++ b/providers/dns/ispconfig/internal/fixtures/client_get_id-request.json @@ -0,0 +1,4 @@ +{ + "session_id": "sessionA", + "sys_userid": "sysA" +} diff --git a/providers/dns/ispconfig/internal/fixtures/client_get_id.json b/providers/dns/ispconfig/internal/fixtures/client_get_id.json new file mode 100644 index 000000000..7b9f667a0 --- /dev/null +++ b/providers/dns/ispconfig/internal/fixtures/client_get_id.json @@ -0,0 +1,5 @@ +{ + "code": "ok", + "message": "foo", + "response": 123 +} diff --git a/providers/dns/ispconfig/internal/fixtures/dns_txt_add-request.json b/providers/dns/ispconfig/internal/fixtures/dns_txt_add-request.json new file mode 100644 index 000000000..bf5242cd1 --- /dev/null +++ b/providers/dns/ispconfig/internal/fixtures/dns_txt_add-request.json @@ -0,0 +1,17 @@ +{ + "session_id": "sessionA", + "client_id": "clientA", + "params": { + "server_id": "serverA", + "zone": "example.com.", + "name": "foo.example.com.", + "type": "txt", + "data": "txtTXTtxt", + "aux": "0", + "ttl": "3600", + "active": "y", + "stamp": "2025-12-25 01:01:01", + "update_serial": true + }, + "update_serial": true +} diff --git a/providers/dns/ispconfig/internal/fixtures/dns_txt_add.json b/providers/dns/ispconfig/internal/fixtures/dns_txt_add.json new file mode 100644 index 000000000..7980619fe --- /dev/null +++ b/providers/dns/ispconfig/internal/fixtures/dns_txt_add.json @@ -0,0 +1,5 @@ +{ + "code": "ok", + "message": "foo", + "response": "123" +} diff --git a/providers/dns/ispconfig/internal/fixtures/dns_txt_delete-request.json b/providers/dns/ispconfig/internal/fixtures/dns_txt_delete-request.json new file mode 100644 index 000000000..240976654 --- /dev/null +++ b/providers/dns/ispconfig/internal/fixtures/dns_txt_delete-request.json @@ -0,0 +1,5 @@ +{ + "session_id": "sessionA", + "primary_id": "123", + "update_serial": true +} diff --git a/providers/dns/ispconfig/internal/fixtures/dns_txt_delete.json b/providers/dns/ispconfig/internal/fixtures/dns_txt_delete.json new file mode 100644 index 000000000..960b650bd --- /dev/null +++ b/providers/dns/ispconfig/internal/fixtures/dns_txt_delete.json @@ -0,0 +1,5 @@ +{ + "code": "ok", + "message": "foo", + "response": 1 +} diff --git a/providers/dns/ispconfig/internal/fixtures/dns_txt_get-request.json b/providers/dns/ispconfig/internal/fixtures/dns_txt_get-request.json new file mode 100644 index 000000000..8bda44067 --- /dev/null +++ b/providers/dns/ispconfig/internal/fixtures/dns_txt_get-request.json @@ -0,0 +1,7 @@ +{ + "session_id": "sessionA", + "primary_id": { + "name": "example.com.", + "type": "txt" + } +} diff --git a/providers/dns/ispconfig/internal/fixtures/dns_txt_get.json b/providers/dns/ispconfig/internal/fixtures/dns_txt_get.json new file mode 100644 index 000000000..f707d50c3 --- /dev/null +++ b/providers/dns/ispconfig/internal/fixtures/dns_txt_get.json @@ -0,0 +1,7 @@ +{ + "code": "ok", + "message": "foo", + "response": { + "id": 123 + } +} diff --git a/providers/dns/ispconfig/internal/fixtures/dns_zone_get-request.json b/providers/dns/ispconfig/internal/fixtures/dns_zone_get-request.json new file mode 100644 index 000000000..3d44d468f --- /dev/null +++ b/providers/dns/ispconfig/internal/fixtures/dns_zone_get-request.json @@ -0,0 +1,4 @@ +{ + "primary_id": "example.com.", + "session_id": "sessionA" +} diff --git a/providers/dns/ispconfig/internal/fixtures/dns_zone_get.json b/providers/dns/ispconfig/internal/fixtures/dns_zone_get.json new file mode 100644 index 000000000..37975d0e6 --- /dev/null +++ b/providers/dns/ispconfig/internal/fixtures/dns_zone_get.json @@ -0,0 +1,32 @@ +{ + "code": "ok", + "message": "foo", + "response": { + "id": "456", + "sys_userid": "789", + "sys_groupid": "2", + "sys_perm_user": "riud", + "sys_perm_group": "riud", + "sys_perm_other": "", + "server_id": "123", + "origin": "example.com.", + "ns": "ns1.example.org.", + "mbox": "support.example.net.", + "serial": "2025102902", + "refresh": "7200", + "retry": "540", + "expire": "604800", + "minimum": "3600", + "ttl": "3600", + "active": "Y", + "xfer": "", + "also_notify": "", + "update_acl": "", + "dnssec_initialized": "N", + "dnssec_wanted": "N", + "dnssec_algo": "ECDSAP256SHA256", + "dnssec_last_signed": "0", + "dnssec_info": "", + "rendered_zone": "" + } +} diff --git a/providers/dns/ispconfig/internal/fixtures/dns_zone_get_id-request.json b/providers/dns/ispconfig/internal/fixtures/dns_zone_get_id-request.json new file mode 100644 index 000000000..e3084242e --- /dev/null +++ b/providers/dns/ispconfig/internal/fixtures/dns_zone_get_id-request.json @@ -0,0 +1,4 @@ +{ + "origin": "example.com", + "session_id": "sessionA" +} diff --git a/providers/dns/ispconfig/internal/fixtures/dns_zone_get_id.json b/providers/dns/ispconfig/internal/fixtures/dns_zone_get_id.json new file mode 100644 index 000000000..7b9f667a0 --- /dev/null +++ b/providers/dns/ispconfig/internal/fixtures/dns_zone_get_id.json @@ -0,0 +1,5 @@ +{ + "code": "ok", + "message": "foo", + "response": 123 +} diff --git a/providers/dns/ispconfig/internal/fixtures/error.json b/providers/dns/ispconfig/internal/fixtures/error.json new file mode 100644 index 000000000..a9c76546c --- /dev/null +++ b/providers/dns/ispconfig/internal/fixtures/error.json @@ -0,0 +1,5 @@ +{ + "code": "remote_fault", + "message": "The login failed. Username or password wrong.", + "response": false +} diff --git a/providers/dns/ispconfig/internal/fixtures/login-request.json b/providers/dns/ispconfig/internal/fixtures/login-request.json new file mode 100644 index 000000000..c3293a2e8 --- /dev/null +++ b/providers/dns/ispconfig/internal/fixtures/login-request.json @@ -0,0 +1,5 @@ +{ + "username": "user", + "password": "secret", + "client_login": false +} diff --git a/providers/dns/ispconfig/internal/fixtures/login.json b/providers/dns/ispconfig/internal/fixtures/login.json new file mode 100644 index 000000000..e380a86ec --- /dev/null +++ b/providers/dns/ispconfig/internal/fixtures/login.json @@ -0,0 +1,5 @@ +{ + "code": "ok", + "message": "foo", + "response": "abc" +} diff --git a/providers/dns/ispconfig/internal/readme.md b/providers/dns/ispconfig/internal/readme.md new file mode 100644 index 000000000..2284c338f --- /dev/null +++ b/providers/dns/ispconfig/internal/readme.md @@ -0,0 +1,249 @@ +## Error Response + +```json +{ + "code": "", + "message": "", + "response": false +} +``` + +## Login Endpoint + +* URL: `?login` +* HTTP Method: `POST` + +- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/login.html +- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/login.php + +### Request Body (JSON) + +```json +{ + "username": "", + "password": "", + "client_login": false +} +``` + +### Response Body (JSON) + +```json +{ + "code": "ok", + "message": "foo", + "response": "abc" +} +``` + +- `response`: is the `sessionID` + +## Get Client ID Endpoint + +* URL: `?client_get_id` +* HTTP Method: `POST` + +- function `client_get_id`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/client.inc.php#L97 +- TABLE `sys_user`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/install/sql/ispconfig3.sql?ref_type=heads#L1852 +- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/client_get_id.html +- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/client_get_id.php + +### Request Body (JSON) + +```json +{ + "session_id": "", + "sys_userid": "" +} +``` + +### Response Body (JSON) + +```json +{ + "code": "ok", + "message": "foo", + "response": 123 +} +``` + +## DNS Zone Get ID Endpoint + +* URL: `?dns_zone_get_id` +* HTTP Method: `POST` + +- function `dns_zone_get_id`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L142 +- TABLE `dns_soa`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/install/sql/ispconfig3.sql?ref_type=heads#L615 + +### Request Body (JSON) + +```json +{ + "session_id": "", + "origin": "" +} +``` + +### Response Body (JSON) + +```json +{ + "code": "ok", + "message": "foo", + "response": 123 +} +``` + +## DNS Zone Get Endpoint + +* URL: `?dns_zone_get` +* HTTP Method: `POST` + +- function `dns_zone_get`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L87 +- function `getDataRecord`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remoting_lib.inc.php#L248 +- TABLE `dns_soa`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/install/sql/ispconfig3.sql?ref_type=heads#L615 +- Depending on the request, the response may be an array or an object (`primary_id` can be a string, an array or an object). +- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/dns_zone_get.html +- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/dns_zone_get.php + +### Request Body (JSON) + +```json +{ + "session_id": "", + "primary_id": "" +} +``` + +### Response Body (JSON) + +```json +{ + "code": "ok", + "message": "foo", + "response": { + "id": 456, + "server_id": 123, + "sys_userid": 789 + } +} +``` + +## DNS TXT Get Endpoint + +* URL: `?dns_txt_get` +* HTTP Method: `POST` + +- function `dns_txt_get`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L640 +- function `dns_rr_get`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L195 +- form: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/web/dns/form/dns_txt.tform.php +- TABLE `dns_rr`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/install/sql/ispconfig3.sql?ref_type=heads#L490 +- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/dns_txt_get.html +- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/dns_txt_get.php + +### Request Body (JSON) + +```json +{ + "session_id": "", + "primary_id": { + "name": ".", + "type": "TXT" + } +} +``` + +### Response Body (JSON) + +```json +{ + "code": "ok", + "message": "foo", + "response": { + "id": 123 + } +} +``` + +## DNS TXT Add Endpoint + +* URL: `?dns_txt_add` +* HTTP Method: `POST` + +- function `dns_txt_add`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L645 +- function `dns_rr_add` https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L212 +- form: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/web/dns/form/dns_txt.tform.php +- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/dns_txt_add.html +- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/dns_txt_add.php + +### Request Body (JSON) + +```json +{ + "session_id": "", + "client_id": "", + "params": { + "server_id": "", + "zone": "", + "name": ".", + "type": "txt", + "data": "", + "aux": "0", + "ttl": "3600", + "active": "y", + "stamp": "", + "update_serial": true + }, + "update_serial": true +} +``` + +- `stamp`: (ex: `2025-12-17 23:35:58`) +- `serial`: (ex: `1766010947`) + +### Response Body (JSON) + +```json +{ + "code": "ok", + "message": "foo", + "response": "123" +} +``` + +## DNS TXT Delete Endpoint + +* URL: `?dns_txt_delete` +* HTTP Method: `POST` + +- function `dns_txt_delete`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L655 +- function `dns_rr_delete`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L247 +- form: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/web/dns/form/dns_txt.tform.php +- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/dns_txt_delete.html +- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/dns_txt_delete.php + +### Request Body (JSON) + +```json +{ + "session_id": "", + "primary_id": "", + "update_serial": true +} +``` + +### Response Body (JSON) + +```json +{ + "code": "ok", + "message": "foo", + "response": 1 +} +``` + +--- + +https://www.ispconfig.org/ +https://git.ispconfig.org/ispconfig/ispconfig3 +https://forum.howtoforge.com/#ispconfig-3.23 diff --git a/providers/dns/ispconfig/internal/types.go b/providers/dns/ispconfig/internal/types.go new file mode 100644 index 000000000..7db0846cc --- /dev/null +++ b/providers/dns/ispconfig/internal/types.go @@ -0,0 +1,95 @@ +package internal + +import ( + "encoding/json" + "strings" +) + +type APIError struct { + APIResponse +} + +func (e *APIError) Error() string { + var msg strings.Builder + + msg.WriteString("code: " + e.Code) + + if e.Message != "" { + msg.WriteString(", message: " + e.Message) + } + + if len(e.Response) > 0 { + msg.WriteString(", response: " + string(e.Response)) + } + + return msg.String() +} + +type APIResponse struct { + Code string `json:"code"` + Message string `json:"message"` + Response json.RawMessage `json:"response"` +} + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + ClientLogin bool `json:"client_login"` +} + +type ClientIDRequest struct { + SessionID string `json:"session_id"` + SysUserID string `json:"sys_userid"` +} + +type Zone struct { + ID string `json:"id"` + ServerID string `json:"server_id"` + SysUserID string `json:"sys_userid"` + SysGroupID string `json:"sys_groupid"` + Origin string `json:"origin"` + Serial string `json:"serial"` + Active string `json:"active"` +} + +type GetTXTRequest struct { + SessionID string `json:"session_id"` + PrimaryID struct { + Name string `json:"name"` + Type string `json:"type"` + } `json:"primary_id"` +} + +type Record struct { + ID int `json:"id"` +} + +type AddTXTRequest struct { + SessionID string `json:"session_id"` + ClientID string `json:"client_id"` + Params *RecordParams `json:"params,omitempty"` + UpdateSerial bool `json:"update_serial"` +} + +type RecordParams struct { + ServerID string `json:"server_id"` + Zone string `json:"zone"` + Name string `json:"name"` + // 'a','aaaa','alias','cname','hinfo','mx','naptr','ns','ds','ptr','rp','srv','txt' + Type string `json:"type"` + Data string `json:"data"` + // "0" + Aux string `json:"aux"` + TTL string `json:"ttl"` + // 'n','y' + Active string `json:"active"` + // `2025-12-17 23:35:58` + Stamp string `json:"stamp"` + UpdateSerial bool `json:"update_serial"` +} + +type DeleteTXTRequest struct { + SessionID string `json:"session_id"` + PrimaryID string `json:"primary_id"` + UpdateSerial bool `json:"update_serial"` +} diff --git a/providers/dns/ispconfig/ispconfig.go b/providers/dns/ispconfig/ispconfig.go new file mode 100644 index 000000000..9396430b7 --- /dev/null +++ b/providers/dns/ispconfig/ispconfig.go @@ -0,0 +1,220 @@ +// Package ispconfig implements a DNS provider for solving the DNS-01 challenge using ISPConfig. +package ispconfig + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net/http" + "strconv" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/ispconfig/internal" +) + +// Environment variables names. +const ( + envNamespace = "ISPCONFIG_" + + EnvServerURL = envNamespace + "SERVER_URL" + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "PASSWORD" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" + EnvInsecureSkipVerify = envNamespace + "INSECURE_SKIP_VERIFY" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + ServerURL string + Username string + Password string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client + InsecureSkipVerify bool +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + recordIDs map[string]string + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for ISPConfig. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvServerURL, EnvUsername, EnvPassword) + if err != nil { + return nil, fmt.Errorf("ispconfig: %w", err) + } + + config := NewDefaultConfig() + config.ServerURL = values[EnvServerURL] + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + config.InsecureSkipVerify = env.GetOrDefaultBool(EnvInsecureSkipVerify, false) + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for ISPConfig. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("ispconfig: the configuration of the DNS provider is nil") + } + + if config.ServerURL == "" { + return nil, errors.New("ispconfig: missing server URL") + } + + if config.Username == "" || config.Password == "" { + return nil, errors.New("ispconfig: credentials missing") + } + + client, err := internal.NewClient(config.ServerURL) + if err != nil { + return nil, fmt.Errorf("ispconfig: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + if config.InsecureSkipVerify { + client.HTTPClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]string), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + sessionID, err := d.client.Login(ctx, d.config.Username, d.config.Password) + if err != nil { + return fmt.Errorf("ispconfig: login: %w", err) + } + + zoneID, err := d.findZone(ctx, sessionID, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("ispconfig: get zone id: %w", err) + } + + zone, err := d.client.GetZone(ctx, sessionID, strconv.Itoa(zoneID)) + if err != nil { + return fmt.Errorf("ispconfig: get zone: %w", err) + } + + clientID, err := d.client.GetClientID(ctx, sessionID, zone.SysUserID) + if err != nil { + return fmt.Errorf("ispconfig: get client id: %w", err) + } + + params := internal.RecordParams{ + ServerID: "serverA", + Zone: zone.ID, + Name: info.EffectiveFQDN, + Type: "txt", + Data: info.Value, + Aux: "0", + TTL: strconv.Itoa(d.config.TTL), + Active: "y", + Stamp: time.Now().UTC().Format("2006-01-02 15:04:05"), + } + + recordID, err := d.client.AddTXT(ctx, sessionID, strconv.Itoa(clientID), params) + if err != nil { + return fmt.Errorf("ispconfig: add txt record: %w", err) + } + + d.recordIDsMu.Lock() + d.recordIDs[token] = recordID + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + // gets the record's unique ID + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + + if !ok { + return fmt.Errorf("ispconfig: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + sessionID, err := d.client.Login(ctx, d.config.Username, d.config.Password) + if err != nil { + return fmt.Errorf("ispconfig: login: %w", err) + } + + _, err = d.client.DeleteTXT(ctx, sessionID, recordID) + if err != nil { + return fmt.Errorf("ispconfig: delete txt record: %w", err) + } + + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) findZone(ctx context.Context, sessionID, fqdn string) (int, error) { + for domain := range dns01.UnFqdnDomainsSeq(fqdn) { + zoneID, err := d.client.GetZoneID(ctx, sessionID, domain) + if err == nil { + return zoneID, nil + } + } + + return 0, fmt.Errorf("zone not found for %q", fqdn) +} diff --git a/providers/dns/ispconfig/ispconfig.toml b/providers/dns/ispconfig/ispconfig.toml new file mode 100644 index 000000000..4defd5509 --- /dev/null +++ b/providers/dns/ispconfig/ispconfig.toml @@ -0,0 +1,27 @@ +Name = "ISPConfig 3" +Description = '''''' +URL = "https://www.ispconfig.org/" +Code = "ispconfig" +Since = "v4.31.0" + +Example = ''' +ISPCONFIG_SERVER_URL="https://example.com:8080/remote/json.php" \ +ISPCONFIG_USERNAME="xxx" \ +ISPCONFIG_PASSWORD="yyy" \ +lego --dns ispconfig -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + ISPCONFIG_SERVER_URL = "Server URL" + ISPCONFIG_USERNAME = "Username" + ISPCONFIG_PASSWORD = "Password" + [Configuration.Additional] + ISPCONFIG_INSECURE_SKIP_VERIFY = "Whether to verify the API certificate" + ISPCONFIG_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + ISPCONFIG_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + ISPCONFIG_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + ISPCONFIG_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/index.html" diff --git a/providers/dns/ispconfig/ispconfig_test.go b/providers/dns/ispconfig/ispconfig_test.go new file mode 100644 index 000000000..b03463aee --- /dev/null +++ b/providers/dns/ispconfig/ispconfig_test.go @@ -0,0 +1,173 @@ +package ispconfig + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvServerURL, + EnvUsername, + EnvPassword, +).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvServerURL: "https://example.com:80/", + EnvUsername: "user", + EnvPassword: "secret", + }, + }, + { + desc: "missing server URL", + envVars: map[string]string{ + EnvServerURL: "", + EnvUsername: "user", + EnvPassword: "secret", + }, + expected: "ispconfig: some credentials information are missing: ISPCONFIG_SERVER_URL", + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvServerURL: "https://example.com:80/", + EnvUsername: "", + EnvPassword: "secret", + }, + expected: "ispconfig: some credentials information are missing: ISPCONFIG_USERNAME", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvServerURL: "https://example.com:80/", + EnvUsername: "user", + EnvPassword: "", + }, + expected: "ispconfig: some credentials information are missing: ISPCONFIG_PASSWORD", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "ispconfig: some credentials information are missing: ISPCONFIG_SERVER_URL,ISPCONFIG_USERNAME,ISPCONFIG_PASSWORD", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + serverURL string + username string + password string + expected string + }{ + { + desc: "success", + serverURL: "https://example.com:80/", + username: "user", + password: "secret", + }, + { + desc: "missing server URL", + username: "user", + password: "secret", + expected: "ispconfig: missing server URL", + }, + { + desc: "missing username", + serverURL: "https://example.com:80/", + password: "secret", + expected: "ispconfig: credentials missing", + }, + { + desc: "missing password", + serverURL: "https://example.com:80/", + username: "user", + expected: "ispconfig: credentials missing", + }, + { + desc: "missing credentials", + expected: "ispconfig: missing server URL", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.ServerURL = test.serverURL + config.Username = test.username + config.Password = test.password + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/ispconfigddns/internal/client.go b/providers/dns/ispconfigddns/internal/client.go new file mode 100644 index 000000000..700b58f89 --- /dev/null +++ b/providers/dns/ispconfigddns/internal/client.go @@ -0,0 +1,111 @@ +package internal + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" + querystring "github.com/google/go-querystring/query" +) + +const ( + addAction = "add" + deleteAction = "delete" +) + +type Client struct { + token string + serverURL string + + HTTPClient *http.Client +} + +func NewClient(serverURL, token string) (*Client, error) { + _, err := url.Parse(serverURL) + if err != nil { + return nil, fmt.Errorf("server URL: %w", err) + } + + return &Client{ + serverURL: serverURL, + token: token, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) AddTXTRecord(ctx context.Context, zone, fqdn, content string) error { + return c.updateRecord(ctx, UpdateRecord{Action: addAction, Zone: zone, Type: "TXT", Record: fqdn, Data: content}) +} + +func (c *Client) DeleteTXTRecord(ctx context.Context, zone, fqdn, recordContent string) error { + return c.updateRecord(ctx, UpdateRecord{Action: deleteAction, Zone: zone, Type: "TXT", Record: fqdn, Data: recordContent}) +} + +func (c *Client) updateRecord(ctx context.Context, action UpdateRecord) error { + req, err := c.newRequest(ctx, action) + if err != nil { + return err + } + + return c.do(req) +} + +func (c *Client) do(req *http.Request) error { + useragent.SetHeader(req.Header) + + req.SetBasicAuth("anonymous", c.token) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + // The endpoint uses the `DefaultDdnsResponseWriter`, + // and this writer uses HTTP status code to determine if the request was successful or not. + // - https://github.com/mhofer117/ispconfig-ddns-module/blob/8b011a5bb138881d9f13360a5c4fec10c0084613/lib/updater/DdnsUpdater.php#L53-L57 + // - https://github.com/mhofer117/ispconfig-ddns-module/blob/master/lib/updater/response/DefaultDdnsResponseWriter.php + if resp.StatusCode/100 != 2 { + raw, _ := io.ReadAll(resp.Body) + + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return nil +} + +func (c *Client) newRequest(ctx context.Context, action UpdateRecord) (*http.Request, error) { + endpoint, err := url.Parse(c.serverURL) + if err != nil { + return nil, err + } + + endpoint = endpoint.JoinPath("ddns", "update.php") + + values, err := querystring.Values(action) + if err != nil { + return nil, err + } + + endpoint.RawQuery = values.Encode() + + method := http.MethodPost + if action.Action == deleteAction { + method = http.MethodDelete + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + + return req, nil +} diff --git a/providers/dns/ispconfigddns/internal/client_test.go b/providers/dns/ispconfigddns/internal/client_test.go new file mode 100644 index 000000000..774e5ee46 --- /dev/null +++ b/providers/dns/ispconfigddns/internal/client_test.go @@ -0,0 +1,83 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +func setupClient(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + + return client, nil +} + +func TestClient_AddTXTRecord(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /ddns/update.php", + servermock.Noop(), + servermock.CheckHeader(). + WithBasicAuth("anonymous", "secret"), + servermock.CheckQueryParameter().Strict(). + With("action", "add"). + With("zone", "example.com"). + With("type", "TXT"). + With("record", "_acme-challenge.example.com."). + With("data", "token"), + ). + Build(t) + + err := client.AddTXTRecord(t.Context(), "example.com", "_acme-challenge.example.com.", "token") + require.NoError(t, err) +} + +func TestClient_AddTXTRecord_error(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /ddns/update.php", + servermock.RawStringResponse("Missing or invalid token."). + WithStatusCode(http.StatusUnauthorized), + ). + Build(t) + + err := client.AddTXTRecord(t.Context(), "example.com", "_acme-challenge.example.com.", "token") + require.EqualError(t, err, "unexpected status code: [status code: 401] body: Missing or invalid token.") +} + +func TestClient_DeleteTXTRecord(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("DELETE /ddns/update.php", + servermock.Noop(), + servermock.CheckHeader(). + WithBasicAuth("anonymous", "secret"), + servermock.CheckQueryParameter().Strict(). + With("action", "delete"). + With("zone", "example.com"). + With("type", "TXT"). + With("record", "_acme-challenge.example.com."). + With("data", "token"), + ). + Build(t) + + err := client.DeleteTXTRecord(t.Context(), "example.com", "_acme-challenge.example.com.", "token") + require.NoError(t, err) +} + +func TestClient_DeleteTXTRecord_error(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("DELETE /ddns/update.php", + servermock.RawStringResponse("Missing or invalid token."). + WithStatusCode(http.StatusUnauthorized), + ). + Build(t) + + err := client.DeleteTXTRecord(t.Context(), "example.com", "_acme-challenge.example.com.", "token") + require.EqualError(t, err, "unexpected status code: [status code: 401] body: Missing or invalid token.") +} diff --git a/providers/dns/ispconfigddns/internal/types.go b/providers/dns/ispconfigddns/internal/types.go new file mode 100644 index 000000000..278738108 --- /dev/null +++ b/providers/dns/ispconfigddns/internal/types.go @@ -0,0 +1,9 @@ +package internal + +type UpdateRecord struct { + Action string `url:"action,omitempty"` + Zone string `url:"zone,omitempty"` + Type string `url:"type,omitempty"` + Record string `url:"record,omitempty"` + Data string `url:"data,omitempty"` +} diff --git a/providers/dns/ispconfigddns/ispconfigddns.go b/providers/dns/ispconfigddns/ispconfigddns.go new file mode 100644 index 000000000..eab5d413f --- /dev/null +++ b/providers/dns/ispconfigddns/ispconfigddns.go @@ -0,0 +1,145 @@ +// Package ispconfigddns implements a DNS provider for solving the DNS-01 challenge using ISPConfig 3 Dynamic DNS (DDNS) Module. +package ispconfigddns + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/ispconfigddns/internal" +) + +// Environment variables names. +const ( + envNamespace = "ISPCONFIG_DDNS_" + + EnvServerURL = envNamespace + "SERVER_URL" + EnvToken = envNamespace + "TOKEN" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + ServerURL string + Token string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, 3600), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for ISPConfig 3 Dynamic DNS (DDNS) Module. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvServerURL, EnvToken) + if err != nil { + return nil, fmt.Errorf("ispconfig (DDNS module): %w", err) + } + + config := NewDefaultConfig() + config.ServerURL = values[EnvServerURL] + config.Token = values[EnvToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for ISPConfig 3 Dynamic DNS (DDNS) Module. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("ispconfig (DDNS module): the configuration of the DNS provider is nil") + } + + if config.ServerURL == "" { + return nil, errors.New("ispconfig (DDNS module): missing server URL") + } + + if config.Token == "" { + return nil, errors.New("ispconfig (DDNS module): missing token") + } + + client, err := internal.NewClient(config.ServerURL, config.Token) + if err != nil { + return nil, fmt.Errorf("ispconfig (DDNS module): %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to control checking compliance to spec. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("ispconfig (DDNS module): could not find zone for domain %q: %w", domain, err) + } + + err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(zone), info.EffectiveFQDN, info.Value) + if err != nil { + return fmt.Errorf("ispconfig (DDNS module): add record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("ispconfig (DDNS module): could not find zone for domain %q: %w", domain, err) + } + + err = d.client.DeleteTXTRecord(context.Background(), dns01.UnFqdn(zone), info.EffectiveFQDN, info.Value) + if err != nil { + return fmt.Errorf("ispconfig (DDNS module): delete record: %w", err) + } + + return nil +} diff --git a/providers/dns/ispconfigddns/ispconfigddns.toml b/providers/dns/ispconfigddns/ispconfigddns.toml new file mode 100644 index 000000000..158ee9fbd --- /dev/null +++ b/providers/dns/ispconfigddns/ispconfigddns.toml @@ -0,0 +1,32 @@ +Name = "ISPConfig 3 - Dynamic DNS (DDNS) Module" +Description = '''''' +URL = "https://www.ispconfig.org/" +Code = "ispconfigddns" +Since = "v4.31.0" + +Example = ''' +ISPCONFIG_DDNS_SERVER_URL="https://panel.example.com:8080" \ +ISPCONFIG_DDNS_TOKEN=xxxxxx \ +lego --dns ispconfigddns -d '*.example.com' -d example.com run +''' + +Additional = ''' +ISPConfig DNS provider supports leveraging the [ISPConfig 3 Dynamic DNS (DDNS) Module](https://github.com/mhofer117/ispconfig-ddns-module). + +Requires the DDNS module described at https://www.ispconfig.org/ispconfig/download/ + +See https://www.howtoforge.com/community/threads/ispconfig-3-danymic-dns-ddns-module.87967/ for additional details. +''' + +[Configuration] + [Configuration.Credentials] + ISPCONFIG_DDNS_SERVER_URL = "API server URL (ex: https://panel.example.com:8080)" + ISPCONFIG_DDNS_TOKEN = "DDNS API token" + [Configuration.Additional] + ISPCONFIG_DDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + ISPCONFIG_DDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + ISPCONFIG_DDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" + ISPCONFIG_DDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://github.com/mhofer117/ispconfig-ddns-module/tree/master/lib/updater" diff --git a/providers/dns/ispconfigddns/ispconfigddns_test.go b/providers/dns/ispconfigddns/ispconfigddns_test.go new file mode 100644 index 000000000..58e7a8f54 --- /dev/null +++ b/providers/dns/ispconfigddns/ispconfigddns_test.go @@ -0,0 +1,193 @@ +package ispconfigddns + +import ( + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvServerURL, EnvToken). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvServerURL: "https://example.com", + EnvToken: "secret", + }, + }, + { + desc: "missing server URL", + envVars: map[string]string{ + EnvServerURL: "", + EnvToken: "secret", + }, + expected: "ispconfig (DDNS module): some credentials information are missing: ISPCONFIG_DDNS_SERVER_URL", + }, + { + desc: "missing token", + envVars: map[string]string{ + EnvServerURL: "https://example.com", + EnvToken: "", + }, + expected: "ispconfig (DDNS module): some credentials information are missing: ISPCONFIG_DDNS_TOKEN", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "ispconfig (DDNS module): some credentials information are missing: ISPCONFIG_DDNS_SERVER_URL,ISPCONFIG_DDNS_TOKEN", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + serverURL string + token string + expected string + }{ + { + desc: "success", + serverURL: "https://example.com", + token: "secret", + }, + { + desc: "missing server URL", + serverURL: "", + token: "secret", + expected: "ispconfig (DDNS module): missing server URL", + }, + { + desc: "missing token", + serverURL: "https://example.com", + token: "", + expected: "ispconfig (DDNS module): missing token", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.ServerURL = test.serverURL + config.Token = test.token + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.HTTPClient = server.Client() + config.Token = "secret" + config.ServerURL = server.URL + + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader(). + WithBasicAuth("anonymous", "secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("POST /ddns/update.php", + servermock.DumpRequest(), + servermock.CheckQueryParameter().Strict(). + With("action", "add"). + With("zone", "example.com"). + With("type", "TXT"). + With("record", "_acme-challenge.example.com."). + With("data", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("DELETE /ddns/update.php", + servermock.DumpRequest(), + servermock.CheckQueryParameter().Strict(). + With("action", "delete"). + With("zone", "example.com"). + With("type", "TXT"). + With("record", "_acme-challenge.example.com."). + With("data", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"), + ). + Build(t) + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/iwantmyname/internal/client.go b/providers/dns/iwantmyname/internal/client.go deleted file mode 100644 index 7a7c50e20..000000000 --- a/providers/dns/iwantmyname/internal/client.go +++ /dev/null @@ -1,66 +0,0 @@ -package internal - -import ( - "context" - "fmt" - "net/http" - "net/url" - "time" - - "github.com/go-acme/lego/v4/providers/dns/internal/errutils" - querystring "github.com/google/go-querystring/query" -) - -const defaultBaseURL = "https://iwantmyname.com/basicauth/ddns" - -// Client iwantmyname client. -type Client struct { - username string - password string - - baseURL *url.URL - HTTPClient *http.Client -} - -// NewClient creates a new Client. -func NewClient(username string, password string) *Client { - baseURL, _ := url.Parse(defaultBaseURL) - - return &Client{ - username: username, - password: password, - baseURL: baseURL, - HTTPClient: &http.Client{Timeout: 10 * time.Second}, - } -} - -// SendRequest send a request (create/add/delete) to the API. -func (c Client) SendRequest(ctx context.Context, record Record) error { - values, err := querystring.Values(record) - if err != nil { - return err - } - - endpoint := c.baseURL - endpoint.RawQuery = values.Encode() - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), http.NoBody) - if err != nil { - return fmt.Errorf("unable to create request: %w", err) - } - - req.SetBasicAuth(c.username, c.password) - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return errutils.NewHTTPDoError(req, err) - } - - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode/100 != 2 { - return errutils.NewUnexpectedResponseStatusCodeError(req, resp) - } - - return nil -} diff --git a/providers/dns/iwantmyname/internal/client_test.go b/providers/dns/iwantmyname/internal/client_test.go deleted file mode 100644 index b26f7c0f0..000000000 --- a/providers/dns/iwantmyname/internal/client_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package internal - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/stretchr/testify/require" -) - -func checkParameter(query url.Values, key, expected string) error { - if query.Get(key) != expected { - return fmt.Errorf("%s: want %s got %s", key, expected, query.Get(key)) - } - return nil -} - -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func TestClient_Do(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - username, password, ok := req.BasicAuth() - if !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if username != "user" { - http.Error(rw, fmt.Sprintf("username: want %s got %s", username, "user"), http.StatusUnauthorized) - return - } - - if password != "secret" { - http.Error(rw, fmt.Sprintf("password: want %s got %s", password, "secret"), http.StatusUnauthorized) - return - } - - query := req.URL.Query() - - values := map[string]string{ - "hostname": "example.com", - "type": "TXT", - "value": "data", - "ttl": "120", - } - - for k, v := range values { - err := checkParameter(query, k, v) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - } - }) - - record := Record{ - Hostname: "example.com", - Type: "TXT", - Value: "data", - TTL: 120, - } - - err := client.SendRequest(context.Background(), record) - require.NoError(t, err) -} diff --git a/providers/dns/iwantmyname/internal/types.go b/providers/dns/iwantmyname/internal/types.go deleted file mode 100644 index b259235f5..000000000 --- a/providers/dns/iwantmyname/internal/types.go +++ /dev/null @@ -1,9 +0,0 @@ -package internal - -// Record represents a record. -type Record struct { - Hostname string `url:"hostname,omitempty"` - Type string `url:"type,omitempty"` - Value string `url:"value,omitempty"` - TTL int `url:"ttl,omitempty"` -} diff --git a/providers/dns/iwantmyname/iwantmyname.go b/providers/dns/iwantmyname/iwantmyname.go index e828446ab..f53287e69 100644 --- a/providers/dns/iwantmyname/iwantmyname.go +++ b/providers/dns/iwantmyname/iwantmyname.go @@ -2,15 +2,13 @@ package iwantmyname import ( - "context" "errors" "fmt" "net/http" "time" - "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/platform/config/env" - "github.com/go-acme/lego/v4/providers/dns/iwantmyname/internal" ) // Environment variables names. @@ -26,6 +24,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Username string @@ -38,20 +38,12 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { - return &Config{ - TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), - HTTPClient: &http.Client{ - Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), - }, - } + return &Config{} } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config - client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for iwantmyname. @@ -71,24 +63,7 @@ func NewDNSProvider() (*DNSProvider, error) { // NewDNSProviderConfig return a DNSProvider instance configured for iwantmyname. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { - if config == nil { - return nil, errors.New("iwantmyname: the configuration of the DNS provider is nil") - } - - if config.Username == "" || config.Password == "" { - return nil, errors.New("iwantmyname: credentials missing") - } - - client := internal.NewClient(config.Username, config.Password) - - if config.HTTPClient != nil { - client.HTTPClient = config.HTTPClient - } - - return &DNSProvider{ - config: config, - client: client, - }, nil + return nil, errors.New("iwantmyname: the iwantmyname API has shut down https://github.com/go-acme/lego/issues/2563") } // Timeout returns the timeout and interval to use when checking for DNS propagation. @@ -99,38 +74,10 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, _, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - record := internal.Record{ - Hostname: dns01.UnFqdn(info.EffectiveFQDN), - Type: "TXT", - Value: info.Value, - TTL: d.config.TTL, - } - - err := d.client.SendRequest(context.Background(), record) - if err != nil { - return fmt.Errorf("iwantmyname: %w", err) - } - return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - record := internal.Record{ - Hostname: dns01.UnFqdn(info.EffectiveFQDN), - Type: "TXT", - Value: "delete", - TTL: d.config.TTL, - } - - err := d.client.SendRequest(context.Background(), record) - if err != nil { - return fmt.Errorf("iwantmyname: %w", err) - } - return nil } diff --git a/providers/dns/iwantmyname/iwantmyname.toml b/providers/dns/iwantmyname/iwantmyname.toml index 678977029..a82c2b749 100644 --- a/providers/dns/iwantmyname/iwantmyname.toml +++ b/providers/dns/iwantmyname/iwantmyname.toml @@ -1,5 +1,9 @@ -Name = "iwantmyname" -Description = '''''' +Name = "iwantmyname (Deprecated)" +Description = ''' +The iwantmyname API has shut down. + +https://github.com/go-acme/lego/issues/2563 +''' URL = "https://iwantmyname.com" Code = "iwantmyname" Since = "v4.7.0" @@ -7,7 +11,7 @@ Since = "v4.7.0" Example = ''' IWANTMYNAME_USERNAME=xxxxxxxx \ IWANTMYNAME_PASSWORD=xxxxxxxx \ -lego --email you@example.com --dns iwantmyname -d '*.example.com' -d example.com run +lego --dns iwantmyname -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,10 +19,10 @@ lego --email you@example.com --dns iwantmyname -d '*.example.com' -d example.com IWANTMYNAME_USERNAME = "API username" IWANTMYNAME_PASSWORD = "API password" [Configuration.Additional] - IWANTMYNAME_POLLING_INTERVAL = "Time between DNS propagation check" - IWANTMYNAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - IWANTMYNAME_TTL = "The TTL of the TXT record used for the DNS challenge" - IWANTMYNAME_HTTP_TIMEOUT = "API request timeout" + IWANTMYNAME_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + IWANTMYNAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + IWANTMYNAME_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + IWANTMYNAME_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://iwantmyname.com/developer/domain-dns-api" diff --git a/providers/dns/jdcloud/fixtures/create_record-request.json b/providers/dns/jdcloud/fixtures/create_record-request.json new file mode 100644 index 000000000..581c00fea --- /dev/null +++ b/providers/dns/jdcloud/fixtures/create_record-request.json @@ -0,0 +1,15 @@ +{ + "domainId": "20", + "regionId": "cn-north-1", + "req": { + "hostRecord": "_acme-challenge", + "hostValue": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "jcloudRes": null, + "mxPriority": null, + "port": null, + "ttl": 120, + "type": "TXT", + "viewValue": -1, + "weight": null + } +} diff --git a/providers/dns/jdcloud/fixtures/create_record.json b/providers/dns/jdcloud/fixtures/create_record.json new file mode 100644 index 000000000..08bd3db26 --- /dev/null +++ b/providers/dns/jdcloud/fixtures/create_record.json @@ -0,0 +1,25 @@ +{ + "requestId": "azerty", + "error": { + "code": 0, + "status": "", + "message": "" + }, + "result": { + "dataList": { + "id": 123, + "hostRecord": "_acme-challenge", + "hostValue": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "jcloudRes": false, + "mxPriority": 0, + "port": 0, + "ttl": 120, + "type": "TXT", + "weight": 0, + "viewValue": [ + 1, + 2 + ] + } + } +} diff --git a/providers/dns/jdcloud/fixtures/delete_record.json b/providers/dns/jdcloud/fixtures/delete_record.json new file mode 100644 index 000000000..20525751c --- /dev/null +++ b/providers/dns/jdcloud/fixtures/delete_record.json @@ -0,0 +1,9 @@ +{ + "requestId": "azerty", + "error": { + "code": 0, + "status": "", + "message": "" + }, + "result": {} +} diff --git a/providers/dns/jdcloud/fixtures/describe_domains_page1.json b/providers/dns/jdcloud/fixtures/describe_domains_page1.json new file mode 100644 index 000000000..cde6dcd6f --- /dev/null +++ b/providers/dns/jdcloud/fixtures/describe_domains_page1.json @@ -0,0 +1,55 @@ +{ + "requestId": "azerty", + "error": { + "code": 0, + "status": "", + "message": "" + }, + "result": { + "dataList": [ + { + "id": 1, + "domainName": "1.example" + }, + { + "id": 2, + "domainName": "2.example" + }, + { + "id": 3, + "domainName": "3.example" + }, + { + "id": 4, + "domainName": "4.example" + }, + { + "id": 5, + "domainName": "5.example" + }, + { + "id": 6, + "domainName": "6.example" + }, + { + "id": 7, + "domainName": "7.example" + }, + { + "id": 8, + "domainName": "8.example" + }, + { + "id": 9, + "domainName": "9.example" + }, + { + "id": 10, + "domainName": "10.example" + } + ], + "currentCount": 10, + "totalCount": 20, + "totalPage": 2 + } +} diff --git a/providers/dns/jdcloud/fixtures/describe_domains_page2.json b/providers/dns/jdcloud/fixtures/describe_domains_page2.json new file mode 100644 index 000000000..b1e1560ab --- /dev/null +++ b/providers/dns/jdcloud/fixtures/describe_domains_page2.json @@ -0,0 +1,55 @@ +{ + "requestId": "azerty", + "error": { + "code": 0, + "status": "", + "message": "" + }, + "result": { + "dataList": [ + { + "id": 11, + "domainName": "11.example" + }, + { + "id": 12, + "domainName": "12.example" + }, + { + "id": 13, + "domainName": "13.example" + }, + { + "id": 14, + "domainName": "14.example" + }, + { + "id": 15, + "domainName": "15.example" + }, + { + "id": 16, + "domainName": "16.example" + }, + { + "id": 17, + "domainName": "17.example" + }, + { + "id": 18, + "domainName": "18.example" + }, + { + "id": 19, + "domainName": "19.example" + }, + { + "id": 20, + "domainName": "example.com" + } + ], + "currentCount": 10, + "totalCount": 20, + "totalPage": 2 + } +} diff --git a/providers/dns/jdcloud/jdcloud.go b/providers/dns/jdcloud/jdcloud.go new file mode 100644 index 000000000..7d9ad4e6b --- /dev/null +++ b/providers/dns/jdcloud/jdcloud.go @@ -0,0 +1,217 @@ +// Package jdcloud implements a DNS provider for solving the DNS-01 challenge using JD Cloud. +package jdcloud + +import ( + "errors" + "fmt" + "strconv" + "sync" + "time" + + "github.com/go-acme/jdcloud-sdk-go/core" + "github.com/go-acme/jdcloud-sdk-go/services/domainservice/apis" + jdcclient "github.com/go-acme/jdcloud-sdk-go/services/domainservice/client" + domainservice "github.com/go-acme/jdcloud-sdk-go/services/domainservice/models" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" +) + +// Environment variables names. +const ( + envNamespace = "JDCLOUD_" + + EnvAccessKeyID = envNamespace + "ACCESS_KEY_ID" + EnvAccessKeySecret = envNamespace + "ACCESS_KEY_SECRET" + EnvRegionID = envNamespace + "REGION_ID" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + AccessKeyID string + AccessKeySecret string + RegionID string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPTimeout time.Duration +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *jdcclient.DomainserviceClient + + recordIDs map[string]int + domainIDs map[string]int + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for JD Cloud. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAccessKeyID, EnvAccessKeySecret) + if err != nil { + return nil, fmt.Errorf("jdcloud: %w", err) + } + + config := NewDefaultConfig() + config.AccessKeyID = values[EnvAccessKeyID] + config.AccessKeySecret = values[EnvAccessKeySecret] + + // https://docs.jdcloud.com/en/common-declaration/api/introduction#Region%20Code + config.RegionID = env.GetOrDefaultString(EnvRegionID, "cn-north-1") + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for JD Cloud. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("jdcloud: the configuration of the DNS provider is nil") + } + + if config.AccessKeyID == "" || config.AccessKeySecret == "" { + return nil, errors.New("jdcloud: missing credentials") + } + + cred := core.NewCredentials(config.AccessKeyID, config.AccessKeySecret) + + client := jdcclient.NewDomainserviceClient(cred) + client.DisableLogger() + client.Config.SetTimeout(config.HTTPTimeout) + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]int), + domainIDs: make(map[string]int), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("jdcloud: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("jdcloud: %w", err) + } + + zone, err := d.findZone(dns01.UnFqdn(authZone)) + if err != nil { + return fmt.Errorf("jdcloud: %w", err) + } + + // https://docs.jdcloud.com/cn/jd-cloud-dns/api/createresourcerecord + crrr := apis.NewCreateResourceRecordRequestWithAllParams( + d.config.RegionID, + strconv.Itoa(zone.Id), + &domainservice.AddRR{ + HostRecord: subDomain, + HostValue: info.Value, + Ttl: d.config.TTL, + Type: "TXT", + ViewValue: -1, + }, + ) + + record, err := jdcclient.CreateResourceRecord(d.client, crrr) + if err != nil { + return fmt.Errorf("jdcloud: create resource record: %w", err) + } + + d.recordIDsMu.Lock() + d.domainIDs[token] = zone.Id + d.recordIDs[token] = record.Result.DataList.Id + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + d.recordIDsMu.Lock() + recordID, recordOK := d.recordIDs[token] + domainID, domainOK := d.domainIDs[token] + d.recordIDsMu.Unlock() + + if !recordOK { + return fmt.Errorf("jdcloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + if !domainOK { + return fmt.Errorf("jdcloud: unknown domain ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + // https://docs.jdcloud.com/cn/jd-cloud-dns/api/deleteresourcerecord + drrr := apis.NewDeleteResourceRecordRequestWithAllParams( + d.config.RegionID, + strconv.Itoa(domainID), + strconv.Itoa(recordID), + ) + + _, err := jdcclient.DeleteResourceRecord(d.client, drrr) + if err != nil { + return fmt.Errorf("jdcloud: delete resource record: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) findZone(zone string) (*domainservice.DomainInfo, error) { + // https://docs.jdcloud.com/cn/jd-cloud-dns/api/describedomains + ddr := apis.NewDescribeDomainsRequestWithoutParam() + ddr.SetRegionId(d.config.RegionID) + ddr.SetPageNumber(1) + ddr.SetPageSize(10) + ddr.SetDomainName(zone) + + for { + response, err := jdcclient.DescribeDomains(d.client, ddr) + if err != nil { + return nil, fmt.Errorf("describe domains: %w", err) + } + + for _, d := range response.Result.DataList { + if d.DomainName == zone { + return &d, nil + } + } + + if len(response.Result.DataList) < ddr.PageSize || response.Result.TotalPage <= ddr.PageNumber { + break + } + + ddr.SetPageNumber(ddr.PageNumber + 1) + } + + return nil, errors.New("zone not found") +} diff --git a/providers/dns/jdcloud/jdcloud.toml b/providers/dns/jdcloud/jdcloud.toml new file mode 100644 index 000000000..7ab403822 --- /dev/null +++ b/providers/dns/jdcloud/jdcloud.toml @@ -0,0 +1,27 @@ +Name = "JD Cloud" +Description = '''''' +URL = "https://www.jdcloud.com/" +Code = "jdcloud" +Since = "v4.31.0" + +Example = ''' +JDCLOUD_ACCESS_KEY_ID="xxx" \ +JDCLOUD_ACCESS_KEY_SECRET="yyy" \ +lego --dns jdcloud -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + JDCLOUD_ACCESS_KEY_ID = "Access key ID" + JDCLOUD_ACCESS_KEY_SECRET = "Access key secret" + [Configuration.Additional] + JDCLOUD_REGION_ID = "Region ID (Default: cn-north-1)" + JDCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + JDCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + JDCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + JDCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://docs.jdcloud.com/cn/jd-cloud-dns/api/overview" + Common = "https://docs.jdcloud.com/en/common-declaration/api/introduction" + GoClient = "https://github.com/jdcloud-api/jdcloud-sdk-go" diff --git a/providers/dns/jdcloud/jdcloud_test.go b/providers/dns/jdcloud/jdcloud_test.go new file mode 100644 index 000000000..6b3368938 --- /dev/null +++ b/providers/dns/jdcloud/jdcloud_test.go @@ -0,0 +1,242 @@ +package jdcloud + +import ( + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvAccessKeyID, + EnvAccessKeySecret, + EnvRegionID, +).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAccessKeyID: "abc123", + EnvAccessKeySecret: "secret", + }, + }, + { + desc: "missing access key ID", + envVars: map[string]string{ + EnvAccessKeyID: "", + EnvAccessKeySecret: "secret", + }, + expected: "jdcloud: some credentials information are missing: JDCLOUD_ACCESS_KEY_ID", + }, + { + desc: "missing access key secret", + envVars: map[string]string{ + EnvAccessKeyID: "abc123", + EnvAccessKeySecret: "", + }, + expected: "jdcloud: some credentials information are missing: JDCLOUD_ACCESS_KEY_SECRET", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "jdcloud: some credentials information are missing: JDCLOUD_ACCESS_KEY_ID,JDCLOUD_ACCESS_KEY_SECRET", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + accessKeyID string + accessKeySecret string + expected string + }{ + { + desc: "success", + accessKeyID: "abc123", + accessKeySecret: "secret", + }, + { + desc: "missing access key ID", + accessKeySecret: "secret", + expected: "jdcloud: missing credentials", + }, + { + desc: "missing access key secret", + accessKeyID: "abc123", + expected: "jdcloud: missing credentials", + }, + { + desc: "missing credentials", + expected: "jdcloud: missing credentials", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.AccessKeyID = test.accessKeyID + config.AccessKeySecret = test.accessKeySecret + config.RegionID = "cn-north-1" + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.AccessKeyID = "abc123" + config.AccessKeySecret = "secret" + config.RegionID = "cn-north-1" + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + serverURL, _ := url.Parse(server.URL) + + p.client.Config.SetEndpoint(net.JoinHostPort(serverURL.Hostname(), serverURL.Port())) + p.client.Config.SetScheme(serverURL.Scheme) + p.client.Config.SetTimeout(server.Client().Timeout) + + return p, nil + }, + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("GET /v2/regions/cn-north-1/domain", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + pageNumber := req.URL.Query().Get("pageNumber") + + servermock.ResponseFromFixture( + fmt.Sprintf("describe_domains_page%s.json", pageNumber), + ).ServeHTTP(rw, req) + }), + servermock.CheckQueryParameter().Strict(). + With("domainName", "example.com"). + WithRegexp("pageNumber", `(1|2)`). + With("pageSize", "10"), + servermock.CheckHeader(). + WithRegexp("Authorization", + `JDCLOUD2-HMAC-SHA256 Credential=abc123/\d{8}/cn-north-1/domainservice/jdcloud2_request, SignedHeaders=content-type;host;x-jdcloud-date;x-jdcloud-nonce, Signature=\w+`). + WithRegexp("X-Jdcloud-Date", `\d{8}T\d{6}Z`). + WithRegexp("X-Jdcloud-Nonce", `[\w-]+`), + ). + Route("POST /v2/regions/cn-north-1/domain/20/ResourceRecord", + servermock.ResponseFromFixture("create_record.json"), + servermock.CheckRequestJSONBodyFromFixture("create_record-request.json"), + servermock.CheckHeader(). + WithRegexp("Authorization", + `JDCLOUD2-HMAC-SHA256 Credential=abc123/\d{8}/cn-north-1/domainservice/jdcloud2_request, SignedHeaders=content-type;host;x-jdcloud-date;x-jdcloud-nonce, Signature=\w+`). + WithRegexp("X-Jdcloud-Date", `\d{8}T\d{6}Z`). + WithRegexp("X-Jdcloud-Nonce", `[\w-]+`), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) + + require.Len(t, provider.domainIDs, 1) + require.Len(t, provider.recordIDs, 1) + + assert.Equal(t, 20, provider.domainIDs["abc"]) + assert.Equal(t, 123, provider.recordIDs["abc"]) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("DELETE /v2/regions/cn-north-1/domain/20/ResourceRecord/123", + servermock.ResponseFromFixture("delete_record.json"), + servermock.CheckHeader(). + WithRegexp("Authorization", + `JDCLOUD2-HMAC-SHA256 Credential=abc123/\d{8}/cn-north-1/domainservice/jdcloud2_request, SignedHeaders=content-type;host;x-jdcloud-date;x-jdcloud-nonce, Signature=\w+`). + WithRegexp("X-Jdcloud-Date", `\d{8}T\d{6}Z`). + WithRegexp("X-Jdcloud-Nonce", `[\w-]+`), + ). + Build(t) + + provider.domainIDs["abc"] = 20 + provider.recordIDs["abc"] = 123 + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/joker/internal/dmapi/client.go b/providers/dns/joker/internal/dmapi/client.go index 04f4350a9..576410723 100644 --- a/providers/dns/joker/internal/dmapi/client.go +++ b/providers/dns/joker/internal/dmapi/client.go @@ -126,7 +126,7 @@ func parseResponse(message string) *Response { lines, body, _ := strings.Cut(message, "\n\n") - for _, line := range strings.Split(lines, "\n") { + for line := range strings.Lines(lines) { if strings.TrimSpace(line) == "" { continue } @@ -176,12 +176,15 @@ func RemoveTxtEntryFromZone(zone, relative string) (string, bool) { prefix := fmt.Sprintf("%s TXT 0 ", relative) modified := false + var zoneEntries []string - for _, line := range strings.Split(zone, "\n") { + + for line := range strings.Lines(zone) { if strings.HasPrefix(line, prefix) { modified = true continue } + zoneEntries = append(zoneEntries, line) } @@ -192,7 +195,7 @@ func RemoveTxtEntryFromZone(zone, relative string) (string, bool) { func AddTxtEntryToZone(zone, relative, value string, ttl int) string { var zoneEntries []string - for _, line := range strings.Split(zone, "\n") { + for line := range strings.Lines(zone) { zoneEntries = append(zoneEntries, fixTxtLines(line)) } diff --git a/providers/dns/joker/internal/dmapi/client_test.go b/providers/dns/joker/internal/dmapi/client_test.go index dc6653bf0..5b6d68740 100644 --- a/providers/dns/joker/internal/dmapi/client_test.go +++ b/providers/dns/joker/internal/dmapi/client_test.go @@ -7,6 +7,7 @@ import ( "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -23,14 +24,17 @@ const ( serverErrorUsername = "error" ) -func setupTest(t *testing.T) (*http.ServeMux, string) { - t.Helper() +func mockBuilder(auth AuthInfo) *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(auth) + client.BaseURL = server.URL + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - return mux, server.URL + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()) } func TestClient_GetZone(t *testing.T) { @@ -70,30 +74,25 @@ func TestClient_GetZone(t *testing.T) { }, } - mux, serverURL := setupTest(t) + client := mockBuilder(AuthInfo{APIKey: "12345"}). + Route("POST /dns-zone-get", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + authSid := req.FormValue("auth-sid") + domain := req.FormValue("domain") - mux.HandleFunc("/dns-zone-get", func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - - authSid := r.FormValue("auth-sid") - domain := r.FormValue("domain") - - switch { - case authSid == correctAPIKey && domain == "known": - _, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\n\n"+testZone) - case authSid == incorrectAPIKey || (authSid == correctAPIKey && domain == "unknown"): - _, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: Authorization error") - default: - http.NotFound(w, r) - } - }) + switch { + case authSid == correctAPIKey && domain == "known": + _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\n\n"+testZone) + case authSid == incorrectAPIKey || (authSid == correctAPIKey && domain == "unknown"): + _, _ = io.WriteString(rw, "Status-Code: 2202\nStatus-Text: Authorization error") + default: + http.NotFound(rw, req) + } + })). + Build(t) for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := NewClient(AuthInfo{APIKey: "12345"}) - client.BaseURL = serverURL - - response, err := client.GetZone(mockContext(test.authSid), test.domain) + response, err := client.GetZone(mockContext(t, test.authSid), test.domain) if test.expectedError { require.Error(t, err) } else { diff --git a/providers/dns/joker/internal/dmapi/identity.go b/providers/dns/joker/internal/dmapi/identity.go index 351d987e9..63c0b2ea1 100644 --- a/providers/dns/joker/internal/dmapi/identity.go +++ b/providers/dns/joker/internal/dmapi/identity.go @@ -24,6 +24,7 @@ type Token struct { // login performs a log in to Joker's DMAPI. func (c *Client) login(ctx context.Context) (*Response, error) { var values url.Values + switch { case c.username != "" && c.password != "": values = url.Values{ @@ -106,5 +107,6 @@ func formatResponseError(response *Response, err error) error { if response != nil { return fmt.Errorf("joker: DMAPI error: %w Response: %v", err, response.Headers) } + return fmt.Errorf("joker: DMAPI error: %w", err) } diff --git a/providers/dns/joker/internal/dmapi/identity_test.go b/providers/dns/joker/internal/dmapi/identity_test.go index 418deaf4f..d2a80f2e6 100644 --- a/providers/dns/joker/internal/dmapi/identity_test.go +++ b/providers/dns/joker/internal/dmapi/identity_test.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "net/http" - "net/http/httptest" "sync/atomic" "testing" "time" @@ -14,12 +13,14 @@ import ( "github.com/stretchr/testify/require" ) -func mockContext(sessionID string) context.Context { +func mockContext(t *testing.T, sessionID string) context.Context { + t.Helper() + if sessionID == "" { sessionID = "xxx" } - return context.WithValue(context.Background(), sessionIDKey, sessionID) + return context.WithValue(t.Context(), sessionIDKey, sessionID) } func TestClient_login_apikey(t *testing.T) { @@ -56,29 +57,24 @@ func TestClient_login_apikey(t *testing.T) { }, } - mux, serverURL := setupTest(t) - - mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - - switch r.FormValue("api-key") { - case correctAPIKey: - _, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet") - case incorrectAPIKey: - _, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error") - case serverErrorAPIKey: - http.NotFound(w, r) - default: - _, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet") - } - }) - for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := NewClient(AuthInfo{APIKey: test.apiKey}) - client.BaseURL = serverURL + client := mockBuilder(AuthInfo{APIKey: test.apiKey}). + Route("POST /login", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + switch req.FormValue("api-key") { + case correctAPIKey: + _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet") + case incorrectAPIKey: + _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error") + case serverErrorAPIKey: + http.NotFound(rw, req) + default: + _, _ = io.WriteString(rw, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet") + } + })). + Build(t) - response, err := client.login(context.Background()) + response, err := client.login(t.Context()) if test.expectedError { require.Error(t, err) } else { @@ -131,29 +127,24 @@ func TestClient_login_username(t *testing.T) { }, } - mux, serverURL := setupTest(t) - - mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - - switch r.FormValue("username") { - case correctUsername: - _, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet") - case incorrectUsername: - _, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error") - case serverErrorUsername: - http.NotFound(w, r) - default: - _, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet") - } - }) - for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := NewClient(AuthInfo{Username: test.username, Password: test.password}) - client.BaseURL = serverURL + client := mockBuilder(AuthInfo{Username: test.username, Password: test.password}). + Route("POST /login", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + switch req.FormValue("username") { + case correctUsername: + _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet") + case incorrectUsername: + _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error") + case serverErrorUsername: + http.NotFound(rw, req) + default: + _, _ = io.WriteString(rw, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet") + } + })). + Build(t) - response, err := client.login(context.Background()) + response, err := client.login(t.Context()) if test.expectedError { require.Error(t, err) } else { @@ -195,28 +186,24 @@ func TestClient_logout(t *testing.T) { }, } - mux, serverURL := setupTest(t) - - mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - - switch r.FormValue("auth-sid") { - case correctAPIKey: - _, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\n") - case incorrectAPIKey: - _, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error") - default: - http.NotFound(w, r) - } - }) - for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := NewClient(AuthInfo{APIKey: "12345"}) - client.BaseURL = serverURL + client := mockBuilder(AuthInfo{APIKey: "12345"}). + Route("POST /logout", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + switch req.FormValue("auth-sid") { + case correctAPIKey: + _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\n") + case incorrectAPIKey: + _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error") + default: + http.NotFound(rw, req) + } + })). + Build(t) + client.token = &Token{SessionID: test.authSid} - response, err := client.Logout(mockContext(test.authSid)) + response, err := client.Logout(mockContext(t, test.authSid)) if test.expectedError { require.Error(t, err) } else { @@ -229,31 +216,23 @@ func TestClient_logout(t *testing.T) { } func TestClient_CreateAuthenticatedContext(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - id := atomic.Int32{} id.Add(100) - mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) + client := mockBuilder(AuthInfo{Username: correctUsername, Password: "secret"}). + Route("POST /login", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + switch req.FormValue("username") { + case correctUsername: + _, _ = fmt.Fprintf(rw, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: %d\n\ncom\nnet", id.Load()) + id.Add(100) - switch r.FormValue("username") { - case correctUsername: - _, _ = fmt.Fprintf(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: %d\n\ncom\nnet", id.Load()) - id.Add(100) + default: + _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error") + } + })). + Build(t) - default: - _, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error") - } - }) - - client := NewClient(AuthInfo{Username: correctUsername, Password: "secret"}) - client.HTTPClient = server.Client() - client.BaseURL = server.URL - - ctx, err := client.CreateAuthenticatedContext(context.Background()) + ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) assert.Equal(t, "100", getSessionID(ctx)) @@ -263,7 +242,7 @@ func TestClient_CreateAuthenticatedContext(t *testing.T) { client.token.SessionID = "cache" client.muToken.Unlock() - ctx, err = client.CreateAuthenticatedContext(context.Background()) + ctx, err = client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) assert.Equal(t, "cache", getSessionID(ctx)) @@ -273,7 +252,7 @@ func TestClient_CreateAuthenticatedContext(t *testing.T) { client.token.ExpireAt = time.Now().UTC().Add(-1 * time.Hour) client.muToken.Unlock() - ctx, err = client.CreateAuthenticatedContext(context.Background()) + ctx, err = client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) assert.Equal(t, "200", getSessionID(ctx)) diff --git a/providers/dns/joker/internal/svc/client_test.go b/providers/dns/joker/internal/svc/client_test.go index 6803ae844..a6cb299e4 100644 --- a/providers/dns/joker/internal/svc/client_test.go +++ b/providers/dns/joker/internal/svc/client_test.go @@ -1,88 +1,66 @@ package svc import ( - "context" - "fmt" - "io" - "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("test", "secret") + client.BaseURL = server.URL + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("test", "secret") - client.BaseURL = server.URL - client.HTTPClient = server.Client() - - return client, mux + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()) } func TestClient_Send(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - all, _ := io.ReadAll(req.Body) - - if string(all) != "label=_acme-challenge&password=secret&type=TXT&username=test&value=123&zone=example.com" { - http.Error(rw, fmt.Sprintf("invalid request: %q", string(all)), http.StatusBadRequest) - return - } - - _, err := rw.Write([]byte("OK: 1 inserted, 0 deleted")) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /", + servermock.RawStringResponse("OK: 1 inserted, 0 deleted"), + servermock.CheckForm().Strict(). + With("zone", "example.com"). + With("label", "_acme-challenge"). + With("type", "TXT"). + With("value", "123"). + With("username", "test"). + With("password", "secret"), + ). + Build(t) zone := "example.com" label := "_acme-challenge" value := "123" - err := client.SendRequest(context.Background(), zone, label, value) + err := client.SendRequest(t.Context(), zone, label, value) require.NoError(t, err) } func TestClient_Send_empty(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - all, _ := io.ReadAll(req.Body) - - if string(all) != "label=_acme-challenge&password=secret&type=TXT&username=test&value=&zone=example.com" { - http.Error(rw, fmt.Sprintf("invalid request: %q", string(all)), http.StatusBadRequest) - return - } - - _, err := rw.Write([]byte("OK: 1 inserted, 0 deleted")) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /", + servermock.RawStringResponse("OK: 1 inserted, 0 deleted"), + servermock.CheckForm().Strict(). + With("zone", "example.com"). + With("label", "_acme-challenge"). + With("type", "TXT"). + With("value", ""). + With("username", "test"). + With("password", "secret"), + ). + Build(t) zone := "example.com" label := "_acme-challenge" value := "" - err := client.SendRequest(context.Background(), zone, label, value) + err := client.SendRequest(t.Context(), zone, label, value) require.NoError(t, err) } diff --git a/providers/dns/joker/joker.toml b/providers/dns/joker/joker.toml index 1f5acf17f..20e481a6d 100644 --- a/providers/dns/joker/joker.toml +++ b/providers/dns/joker/joker.toml @@ -9,17 +9,17 @@ Example = ''' JOKER_API_MODE=SVC \ JOKER_USERNAME= \ JOKER_PASSWORD= \ -lego --email you@example.com --dns joker -d '*.example.com' -d example.com run +lego --dns joker -d '*.example.com' -d example.com run # DMAPI JOKER_API_MODE=DMAPI \ JOKER_USERNAME= \ JOKER_PASSWORD= \ -lego --email you@example.com --dns joker -d '*.example.com' -d example.com run +lego --dns joker -d '*.example.com' -d example.com run ## or JOKER_API_MODE=DMAPI \ JOKER_API_KEY= \ -lego --email you@example.com --dns joker -d '*.example.com' -d example.com run +lego --dns joker -d '*.example.com' -d example.com run ''' Additional = ''' @@ -48,11 +48,11 @@ As per [Joker.com documentation](https://joker.com/faq/content/6/496/en/let_s-en JOKER_PASSWORD = "Joker.com password" JOKER_API_KEY = "API key (only with DMAPI mode)" [Configuration.Additional] - JOKER_POLLING_INTERVAL = "Time between DNS propagation check" - JOKER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - JOKER_TTL = "The TTL of the TXT record used for the DNS challenge" - JOKER_HTTP_TIMEOUT = "API request timeout" - JOKER_SEQUENCE_INTERVAL = "Time between sequential requests (only with 'SVC' mode)" + JOKER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + JOKER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + JOKER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + JOKER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" + JOKER_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60), only with 'SVC' mode" [Links] API = "https://joker.com/faq/category/39/22-dmapi.html" diff --git a/providers/dns/joker/joker_test.go b/providers/dns/joker/joker_test.go index a71e4d9fe..bc21ccbbc 100644 --- a/providers/dns/joker/joker_test.go +++ b/providers/dns/joker/joker_test.go @@ -20,7 +20,7 @@ func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string - expected interface{} + expected any }{ { desc: "mode DMAPI (default)", @@ -53,6 +53,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -72,7 +73,7 @@ func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string mode string - expected interface{} + expected any }{ { desc: "mode DMAPI (default)", @@ -112,6 +113,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -125,6 +127,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/joker/provider_dmapi.go b/providers/dns/joker/provider_dmapi.go index ec85d5705..11f850136 100644 --- a/providers/dns/joker/provider_dmapi.go +++ b/providers/dns/joker/provider_dmapi.go @@ -6,12 +6,16 @@ import ( "fmt" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/joker/internal/dmapi" ) +var _ challenge.ProviderTimeout = (*dmapiProvider)(nil) + // dmapiProvider implements the challenge.Provider interface. type dmapiProvider struct { config *Config @@ -24,6 +28,7 @@ func newDmapiProvider() (*dmapiProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { var errU error + values, errU = env.Get(EnvUsername, EnvPassword) if errU != nil { //nolint:errorlint // false-positive @@ -63,6 +68,8 @@ func newDmapiProviderConfig(config *Config) (*dmapiProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &dmapiProvider{config: config, client: client}, nil } @@ -155,6 +162,7 @@ func (d *dmapiProvider) CleanUp(domain, token, keyAuth string) error { if err != nil { return formatResponseError(response, err) } + return nil } @@ -163,5 +171,6 @@ func formatResponseError(response *dmapi.Response, err error) error { if response != nil { return fmt.Errorf("joker: DMAPI error: %w Response: %v", err, response.Headers) } + return fmt.Errorf("joker: DMAPI error: %w", err) } diff --git a/providers/dns/joker/provider_dmapi_test.go b/providers/dns/joker/provider_dmapi_test.go index 4704f2b80..06f283872 100644 --- a/providers/dns/joker/provider_dmapi_test.go +++ b/providers/dns/joker/provider_dmapi_test.go @@ -58,6 +58,7 @@ func Test_newDmapiProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) diff --git a/providers/dns/joker/provider_svc.go b/providers/dns/joker/provider_svc.go index c9edfded1..f4d8fcf3f 100644 --- a/providers/dns/joker/provider_svc.go +++ b/providers/dns/joker/provider_svc.go @@ -6,11 +6,15 @@ import ( "fmt" "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/joker/internal/svc" ) +var _ challenge.ProviderTimeout = (*svcProvider)(nil) + // svcProvider implements the challenge.Provider interface. type svcProvider struct { config *Config @@ -44,6 +48,8 @@ func newSvcProviderConfig(config *Config) (*svcProvider, error) { client := svc.NewClient(config.Username, config.Password) + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &svcProvider{config: config, client: client}, nil } diff --git a/providers/dns/joker/provider_svc_test.go b/providers/dns/joker/provider_svc_test.go index ad6c74c87..dc981b6b4 100644 --- a/providers/dns/joker/provider_svc_test.go +++ b/providers/dns/joker/provider_svc_test.go @@ -49,6 +49,7 @@ func Test_newSvcProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) diff --git a/providers/dns/keyhelp/internal/client.go b/providers/dns/keyhelp/internal/client.go new file mode 100644 index 000000000..a5a80db5c --- /dev/null +++ b/providers/dns/keyhelp/internal/client.go @@ -0,0 +1,175 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +// APIKeyHeader API key header. +const APIKeyHeader = "X-Api-Key" + +// Client the KeyHelp API client. +type Client struct { + apiKey string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(baseURL, apiKey string) (*Client, error) { + if baseURL == "" { + return nil, errors.New("missing base URL") + } + + if apiKey == "" { + return nil, errors.New("credentials missing") + } + + base, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("parse base URL: %w", err) + } + + return &Client{ + apiKey: apiKey, + baseURL: base.JoinPath("api", "v2"), + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) do(req *http.Request, result any) error { + req.Header.Set(APIKeyHeader, c.apiKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { + endpoint := c.baseURL.JoinPath("domains") + + query := endpoint.Query() + query.Set("sort", "domain_utf8") + endpoint.RawQuery = query.Encode() + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result []Domain + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result, nil +} + +func (c *Client) ListDomainRecords(ctx context.Context, domainID int) (*DomainRecords, error) { + endpoint := c.baseURL.JoinPath("dns", strconv.Itoa(domainID)) + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result DomainRecords + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func (c *Client) UpdateDomainRecords(ctx context.Context, domainID int, records DomainRecords) (*DomainID, error) { + endpoint := c.baseURL.JoinPath("dns", strconv.Itoa(domainID)) + + req, err := newJSONRequest(ctx, http.MethodPut, endpoint, records) + if err != nil { + return nil, err + } + + var result DomainID + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/keyhelp/internal/client_test.go b/providers/dns/keyhelp/internal/client_test.go new file mode 100644 index 000000000..80b21495b --- /dev/null +++ b/providers/dns/keyhelp/internal/client_test.go @@ -0,0 +1,169 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + With(APIKeyHeader, "secret"). + WithJSONHeaders(), + ) +} + +func TestClient_ListDomains(t *testing.T) { + client := mockBuilder(). + Route("GET /api/v2/domains", + servermock.ResponseFromFixture("get_domains.json"), + servermock.CheckQueryParameter(). + With("sort", "domain_utf8"). + Strict()). + Build(t) + + domains, err := client.ListDomains(t.Context()) + require.NoError(t, err) + + expected := []Domain{{ + ID: 8, + UserID: 4, + ParentDomainID: 0, + Status: 1, + Domain: "example.com", + DomainUTF8: "example.com", + IsEmailDomain: true, + }} + + assert.Equal(t, expected, domains) +} + +func TestClient_ListDomains_error(t *testing.T) { + client := mockBuilder(). + Route("GET /api/v2/domains", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + _, err := client.ListDomains(t.Context()) + + require.EqualError(t, err, "401 Unauthorized: API key is missing or invalid.") +} + +func TestClient_ListDomainRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /api/v2/dns/123", + servermock.ResponseFromFixture("get_domain_records.json")). + Build(t) + + domainRecords, err := client.ListDomainRecords(t.Context(), 123) + require.NoError(t, err) + + expected := &DomainRecords{ + DkimRecord: `default._domainkey IN TXT ( "v=DKIM1; k=rsa; s=email; " "...DKIM KEY..." )`, + Records: &Records{ + Soa: &SOARecord{ + TTL: 86400, + PrimaryNs: "ns.example.com.", + RName: "root.example.com.", + Refresh: 14400, + Retry: 1800, + Expire: 604800, + Minimum: 3600, + }, + Other: []Record{{ + Host: "@", + TTL: 86400, + Type: "A", + Value: "192.168.178.1", + }}, + }, + } + + assert.Equal(t, expected, domainRecords) +} + +func TestClient_ListDomainRecords_error(t *testing.T) { + client := mockBuilder(). + Route("GET /api/v2/dns/8", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + _, err := client.ListDomainRecords(t.Context(), 8) + + require.EqualError(t, err, "401 Unauthorized: API key is missing or invalid.") +} + +func TestClient_UpdateDomainRecords(t *testing.T) { + client := mockBuilder(). + Route("PUT /api/v2/dns/8", + servermock.ResponseFromFixture("update_domain_records.json"), + servermock.CheckRequestJSONBodyFromFixture("update_domain_records-request.json")). + Build(t) + + records := DomainRecords{ + DkimRecord: `default._domainkey IN TXT ( "v=DKIM1; k=rsa; s=email; " "...DKIM KEY..." )`, + Records: &Records{ + Soa: &SOARecord{ + TTL: 86400, + PrimaryNs: "ns.example.com.", + RName: "root.example.com.", + Refresh: 14400, + Retry: 1800, + Expire: 604800, + Minimum: 3600, + }, + Other: []Record{ + { + Host: "@", + TTL: 86400, + Type: "A", + Value: "192.168.178.1", + }, + { + Host: "_acme-challenge", + TTL: 120, + Type: "TXT", + Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + }, + }, + }, + } + + domainID, err := client.UpdateDomainRecords(t.Context(), 8, records) + require.NoError(t, err) + + expected := &DomainID{ID: 8} + + assert.Equal(t, expected, domainID) +} + +func TestClient_UpdateDomainRecords_error(t *testing.T) { + client := mockBuilder(). + Route("PUT /api/v2/dns/123", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + records := DomainRecords{} + + _, err := client.UpdateDomainRecords(t.Context(), 123, records) + + require.EqualError(t, err, "401 Unauthorized: API key is missing or invalid.") +} diff --git a/providers/dns/keyhelp/internal/fixtures/error.json b/providers/dns/keyhelp/internal/fixtures/error.json new file mode 100644 index 000000000..4fdf5e8f5 --- /dev/null +++ b/providers/dns/keyhelp/internal/fixtures/error.json @@ -0,0 +1,4 @@ +{ + "code": "401 Unauthorized", + "message": "API key is missing or invalid." +} diff --git a/providers/dns/keyhelp/internal/fixtures/get_domain_records.json b/providers/dns/keyhelp/internal/fixtures/get_domain_records.json new file mode 100644 index 000000000..50483bb8e --- /dev/null +++ b/providers/dns/keyhelp/internal/fixtures/get_domain_records.json @@ -0,0 +1,24 @@ +{ + "is_custom_dns": false, + "is_dns_disabled": false, + "dkim_record": "default._domainkey IN TXT ( \"v=DKIM1; k=rsa; s=email; \" \"...DKIM KEY...\" )", + "records": { + "soa": { + "ttl": 86400, + "primary_ns": "ns.example.com.", + "rname": "root.example.com.", + "refresh": 14400, + "retry": 1800, + "expire": 604800, + "minimum": 3600 + }, + "other": [ + { + "host": "@", + "ttl": 86400, + "type": "A", + "value": "192.168.178.1" + } + ] + } +} diff --git a/providers/dns/keyhelp/internal/fixtures/get_domain_records2.json b/providers/dns/keyhelp/internal/fixtures/get_domain_records2.json new file mode 100644 index 000000000..cd49fd6d0 --- /dev/null +++ b/providers/dns/keyhelp/internal/fixtures/get_domain_records2.json @@ -0,0 +1,30 @@ +{ + "is_custom_dns": false, + "is_dns_disabled": false, + "dkim_record": "default._domainkey IN TXT ( \"v=DKIM1; k=rsa; s=email; \" \"...DKIM KEY...\" )", + "records": { + "soa": { + "ttl": 86400, + "primary_ns": "ns.example.com.", + "rname": "root.example.com.", + "refresh": 14400, + "retry": 1800, + "expire": 604800, + "minimum": 3600 + }, + "other": [ + { + "host": "@", + "ttl": 86400, + "type": "A", + "value": "192.168.178.1" + }, + { + "host": "_acme-challenge", + "ttl": 120, + "type": "TXT", + "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + } + ] + } +} diff --git a/providers/dns/keyhelp/internal/fixtures/get_domains.json b/providers/dns/keyhelp/internal/fixtures/get_domains.json new file mode 100644 index 000000000..28ae0887d --- /dev/null +++ b/providers/dns/keyhelp/internal/fixtures/get_domains.json @@ -0,0 +1,41 @@ +[ + { + "id": 8, + "id_user": 4, + "id_parent_domain": 0, + "status": 1, + "domain": "example.com", + "domain_utf8": "example.com", + "created_at": "2019-08-15T11:29:13+02:00", + "php_version": "", + "traffic": 32434624, + "is_disabled": false, + "delete_on": "2025-09-02T19:31:14+0000", + "dkim_selector": "default", + "dkim_record": "default._domainkey IN TXT ( \"v=DKIM1; k=rsa; s=email; \" \"...DKIM KEY...\" )", + "is_custom_dns": false, + "is_dns_disabled": false, + "is_subdomain": false, + "is_system_domain": false, + "is_email_domain": true, + "is_email_sending_only": false, + "target": { + "target": "https://www.keyhelp.de", + "is_forwarding": true, + "forwarding_type": 301 + }, + "security": { + "id_certificate": 0, + "lets_encrypt": true, + "is_prefer_https": true, + "is_hsts": true, + "hsts_max_age": 10368000, + "hsts_include": true, + "hsts_preload": true + }, + "apache": { + "http_directives": "# My custom HTTP directives", + "https_directives": "# My custom HTTPS directives" + } + } +] diff --git a/providers/dns/keyhelp/internal/fixtures/update_domain_records-request.json b/providers/dns/keyhelp/internal/fixtures/update_domain_records-request.json new file mode 100644 index 000000000..6f83ead11 --- /dev/null +++ b/providers/dns/keyhelp/internal/fixtures/update_domain_records-request.json @@ -0,0 +1,28 @@ +{ + "dkim_record": "default._domainkey IN TXT ( \"v=DKIM1; k=rsa; s=email; \" \"...DKIM KEY...\" )", + "records": { + "soa": { + "ttl": 86400, + "primary_ns": "ns.example.com.", + "rname": "root.example.com.", + "refresh": 14400, + "retry": 1800, + "expire": 604800, + "minimum": 3600 + }, + "other": [ + { + "host": "@", + "ttl": 86400, + "type": "A", + "value": "192.168.178.1" + }, + { + "host": "_acme-challenge", + "ttl": 120, + "type": "TXT", + "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + } + ] + } +} diff --git a/providers/dns/keyhelp/internal/fixtures/update_domain_records-request2.json b/providers/dns/keyhelp/internal/fixtures/update_domain_records-request2.json new file mode 100644 index 000000000..3ebb2ee7a --- /dev/null +++ b/providers/dns/keyhelp/internal/fixtures/update_domain_records-request2.json @@ -0,0 +1,22 @@ +{ + "dkim_record": "default._domainkey IN TXT ( \"v=DKIM1; k=rsa; s=email; \" \"...DKIM KEY...\" )", + "records": { + "soa": { + "ttl": 86400, + "primary_ns": "ns.example.com.", + "rname": "root.example.com.", + "refresh": 14400, + "retry": 1800, + "expire": 604800, + "minimum": 3600 + }, + "other": [ + { + "host": "@", + "ttl": 86400, + "type": "A", + "value": "192.168.178.1" + } + ] + } +} diff --git a/providers/dns/keyhelp/internal/fixtures/update_domain_records.json b/providers/dns/keyhelp/internal/fixtures/update_domain_records.json new file mode 100644 index 000000000..a335b5ba5 --- /dev/null +++ b/providers/dns/keyhelp/internal/fixtures/update_domain_records.json @@ -0,0 +1,3 @@ +{ + "id": 8 +} diff --git a/providers/dns/keyhelp/internal/types.go b/providers/dns/keyhelp/internal/types.go new file mode 100644 index 000000000..8716fa0c8 --- /dev/null +++ b/providers/dns/keyhelp/internal/types.go @@ -0,0 +1,63 @@ +package internal + +import ( + "fmt" +) + +type APIError struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +func (a *APIError) Error() string { + return fmt.Sprintf("%s: %s", a.Code, a.Message) +} + +type Domain struct { + ID int `json:"id,omitempty"` + UserID int `json:"id_user,omitempty"` + ParentDomainID int `json:"id_parent_domain,omitempty"` + Status int `json:"status,omitempty"` + Domain string `json:"domain,omitempty"` + DomainUTF8 string `json:"domain_utf8,omitempty"` + IsDisabled bool `json:"is_disabled,omitempty"` + IsCustomDNS bool `json:"is_custom_dns,omitempty"` + IsDNSDisabled bool `json:"is_dns_disabled,omitempty"` + IsSubdomain bool `json:"is_subdomain,omitempty"` + IsSystemDomain bool `json:"is_system_domain,omitempty"` + IsEmailDomain bool `json:"is_email_domain,omitempty"` + IsEmailSendingOnly bool `json:"is_email_sending_only,omitempty"` +} + +type DomainID struct { + ID int `json:"id,omitempty"` +} + +type DomainRecords struct { + IsCustomDNS bool `json:"is_custom_dns,omitempty"` + IsDNSDisabled bool `json:"is_dns_disabled,omitempty"` + DkimRecord string `json:"dkim_record,omitempty"` + Records *Records `json:"records,omitempty"` +} + +type Records struct { + Soa *SOARecord `json:"soa,omitempty"` + Other []Record `json:"other,omitempty"` +} + +type SOARecord struct { + TTL int `json:"ttl,omitempty"` + PrimaryNs string `json:"primary_ns,omitempty"` + RName string `json:"rname,omitempty"` + Refresh int `json:"refresh,omitempty"` + Retry int `json:"retry,omitempty"` + Expire int `json:"expire,omitempty"` + Minimum int `json:"minimum,omitempty"` +} + +type Record struct { + Host string `json:"host"` + TTL int `json:"ttl"` + Type string `json:"type"` + Value string `json:"value"` +} diff --git a/providers/dns/keyhelp/keyhelp.go b/providers/dns/keyhelp/keyhelp.go new file mode 100644 index 000000000..67ceaaa63 --- /dev/null +++ b/providers/dns/keyhelp/keyhelp.go @@ -0,0 +1,225 @@ +// Package keyhelp implements a DNS provider for solving the DNS-01 challenge using KeyHelp. +package keyhelp + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/keyhelp/internal" +) + +// Environment variables names. +const ( + envNamespace = "KEYHELP_" + + EnvBaseURL = envNamespace + "BASE_URL" + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + BaseURL string + APIKey string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + domainIDs map[string]int + domainIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for KeyHelp. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvBaseURL, EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("keyhelp: %w", err) + } + + config := NewDefaultConfig() + config.BaseURL = values[EnvBaseURL] + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for KeyHelp. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("keyhelp: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.BaseURL, config.APIKey) + if err != nil { + return nil, fmt.Errorf("keyhelp: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + domainIDs: make(map[string]int), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("keyhelp: could not find zone for domain %q: %w", domain, err) + } + + ctx := context.Background() + + domainInfo, err := d.findDomain(ctx, dns01.UnFqdn(authZone)) + if err != nil { + return fmt.Errorf("keyhelp: %w", err) + } + + domainRecords, err := d.client.ListDomainRecords(ctx, domainInfo.ID) + if err != nil { + return fmt.Errorf("keyhelp: list domain records: %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("keyhelp: %w", err) + } + + records := domainRecords.Records.Other + records = append(records, internal.Record{ + Host: subDomain, + TTL: d.config.TTL, + Type: "TXT", + Value: info.Value, + }) + + req := internal.DomainRecords{ + DkimRecord: domainRecords.DkimRecord, + Records: &internal.Records{ + Soa: domainRecords.Records.Soa, + Other: records, + }, + } + + _, err = d.client.UpdateDomainRecords(ctx, domainInfo.ID, req) + if err != nil { + return fmt.Errorf("keyhelp: update domain records (add): %w", err) + } + + d.domainIDsMu.Lock() + d.domainIDs[token] = domainInfo.ID + d.domainIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + // get the domain's unique ID from when we created it + d.domainIDsMu.Lock() + domainID, ok := d.domainIDs[token] + d.domainIDsMu.Unlock() + + if !ok { + return fmt.Errorf("keyhelp: unknown record ID for '%s'", info.EffectiveFQDN) + } + + domainRecords, err := d.client.ListDomainRecords(ctx, domainID) + if err != nil { + return fmt.Errorf("keyhelp: list domain records: %w", err) + } + + var records []internal.Record + + for _, record := range domainRecords.Records.Other { + if record.Type == "TXT" && record.Value == info.Value { + continue + } + + records = append(records, record) + } + + req := internal.DomainRecords{ + DkimRecord: domainRecords.DkimRecord, + Records: &internal.Records{ + Soa: domainRecords.Records.Soa, + Other: records, + }, + } + + _, err = d.client.UpdateDomainRecords(ctx, domainID, req) + if err != nil { + return fmt.Errorf("keyhelp: update domain records (delete): %w", err) + } + + // Delete domain ID from map + d.domainIDsMu.Lock() + delete(d.domainIDs, token) + d.domainIDsMu.Unlock() + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) findDomain(ctx context.Context, zone string) (internal.Domain, error) { + domains, err := d.client.ListDomains(ctx) + if err != nil { + return internal.Domain{}, fmt.Errorf("list domains: %w", err) + } + + for _, domain := range domains { + if domain.DomainUTF8 == zone || domain.Domain == zone { + return domain, nil + } + } + + return internal.Domain{}, fmt.Errorf("domain not found: %s", zone) +} diff --git a/providers/dns/keyhelp/keyhelp.toml b/providers/dns/keyhelp/keyhelp.toml new file mode 100644 index 000000000..e622794ca --- /dev/null +++ b/providers/dns/keyhelp/keyhelp.toml @@ -0,0 +1,24 @@ +Name = "KeyHelp" +Description = '''''' +URL = "https://www.keyweb.de/en/keyhelp/keyhelp/" +Code = "keyhelp" +Since = "v4.26.0" + +Example = ''' +KEYHELP_BASE_URL="https://keyhelp.example.com" \ +KEYHELP_API_KEY="xxx" \ +lego --dns keyhelp -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + KEYHELP_BASE_URL= "Server URL" + KEYHELP_API_KEY = "API key" + [Configuration.Additional] + KEYHELP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + KEYHELP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + KEYHELP_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + KEYHELP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://app.swaggerhub.com/apis-docs/keyhelp/api/" diff --git a/providers/dns/keyhelp/keyhelp_test.go b/providers/dns/keyhelp/keyhelp_test.go new file mode 100644 index 000000000..8d8ac821d --- /dev/null +++ b/providers/dns/keyhelp/keyhelp_test.go @@ -0,0 +1,198 @@ +package keyhelp + +import ( + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/go-acme/lego/v4/providers/dns/keyhelp/internal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvBaseURL, EnvAPIKey). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvBaseURL: "https://keyhelp.example.com", + EnvAPIKey: "secret", + }, + }, + { + desc: "missing base URL", + envVars: map[string]string{ + EnvAPIKey: "secret", + }, + expected: "keyhelp: some credentials information are missing: KEYHELP_BASE_URL", + }, + { + desc: "missing API key", + envVars: map[string]string{ + EnvBaseURL: "https://keyhelp.example.com", + }, + expected: "keyhelp: some credentials information are missing: KEYHELP_API_KEY", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "keyhelp: some credentials information are missing: KEYHELP_BASE_URL,KEYHELP_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + baseURL string + apiKey string + expected string + }{ + { + desc: "success", + baseURL: "https://keyhelp.example.com", + apiKey: "secret", + }, + { + desc: "missing base URL", + apiKey: "secret", + expected: "keyhelp: missing base URL", + }, + { + desc: "missing API key", + baseURL: "https://keyhelp.example.com", + expected: "keyhelp: credentials missing", + }, + { + desc: "missing credentials", + expected: "keyhelp: missing base URL", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.BaseURL = test.baseURL + config.APIKey = test.apiKey + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.HTTPClient = server.Client() + config.APIKey = "secret" + config.BaseURL = server.URL + + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader(). + With(internal.APIKeyHeader, "secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("GET /api/v2/domains", + servermock.ResponseFromInternal("get_domains.json"), + servermock.CheckQueryParameter(). + With("sort", "domain_utf8"). + Strict()). + Route("GET /api/v2/dns/8", + servermock.ResponseFromInternal("get_domain_records.json")). + Route("PUT /api/v2/dns/8", + servermock.ResponseFromInternal("update_domain_records.json"), + servermock.CheckRequestJSONBodyFromInternal("update_domain_records-request.json")). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) + + assert.Equal(t, 8, provider.domainIDs["abc"]) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("GET /api/v2/dns/8", + servermock.ResponseFromInternal("get_domain_records2.json")). + Route("PUT /api/v2/dns/8", + servermock.ResponseFromInternal("update_domain_records.json"), + servermock.CheckRequestJSONBodyFromInternal("update_domain_records-request2.json")). + Build(t) + + provider.domainIDs["abc"] = 8 + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/leaseweb/internal/client.go b/providers/dns/leaseweb/internal/client.go new file mode 100644 index 000000000..01619d49b --- /dev/null +++ b/providers/dns/leaseweb/internal/client.go @@ -0,0 +1,216 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://api.leaseweb.com/hosting/v2" + +const AuthHeader = "X-LSW-Auth" + +// Client the Leaseweb API client. +type Client struct { + apiKey string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(apiKey string) (*Client, error) { + if apiKey == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + apiKey: apiKey, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// CreateRRSet creates a resource record set. +// https://developer.leaseweb.com/docs/#tag/DNS/operation/createResourceRecordSet +func (c *Client) CreateRRSet(ctx context.Context, domainName string, rrset RRSet) (*RRSet, error) { + endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, rrset) + if err != nil { + return nil, err + } + + result := &RRSet{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// GetRRSet gets a resource record set. +// https://developer.leaseweb.com/docs/#tag/DNS/operation/getResourceRecordSet +func (c *Client) GetRRSet(ctx context.Context, domainName, name, rType string) (*RRSet, error) { + endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", name, rType) + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + result := &RRSet{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// UpdateRRSet updates a resource record set. +// https://developer.leaseweb.com/docs/#tag/DNS/operation/updateResourceRecordSet +func (c *Client) UpdateRRSet(ctx context.Context, domainName string, rrset RRSet) (*RRSet, error) { + endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", rrset.Name, rrset.Type) + + // Reset values that are not allowed to be updated. + rrset.Name = "" + rrset.Type = "" + rrset.Editable = false + + req, err := newJSONRequest(ctx, http.MethodPut, endpoint, rrset) + if err != nil { + return nil, err + } + + result := &RRSet{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// DeleteRRSet deletes a resource record set. +// https://developer.leaseweb.com/docs/#tag/DNS/operation/deleteResourceRecordSet +func (c *Client) DeleteRRSet(ctx context.Context, domainName, name, rType string) error { + endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", name, rType) + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) do(req *http.Request, result any) error { + useragent.SetHeader(req.Header) + + req.Header.Add(AuthHeader, c.apiKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + if resp.StatusCode == http.StatusNotFound { + return &NotFoundError{APIError{ + CorrelationID: resp.Header.Get("Correlation-Id"), + ErrorCode: strconv.Itoa(http.StatusNotFound), + ErrorMessage: string(raw), + }} + } + + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + if errAPI.ErrorCode == strconv.Itoa(http.StatusNotFound) { + return &NotFoundError{APIError: errAPI} + } + + return &errAPI +} + +// TTLRounder rounds the given TTL in seconds to the next accepted value. +// Accepted TTL values are: 60, 300, 1800, 3600, 14400, 28800, 43200, 86400. +func TTLRounder(ttl int) int { + for _, validTTL := range []int{60, 300, 1800, 3600, 14400, 28800, 43200, 86400} { + if ttl <= validTTL { + return validTTL + } + } + + return 3600 +} diff --git a/providers/dns/leaseweb/internal/client_test.go b/providers/dns/leaseweb/internal/client_test.go new file mode 100644 index 000000000..5762aad4b --- /dev/null +++ b/providers/dns/leaseweb/internal/client_test.go @@ -0,0 +1,149 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With(AuthHeader, "secret"), + ) +} + +func TestClient_CreateRRSet(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/example.com/resourceRecordSets", + servermock.ResponseFromFixture("createResourceRecordSet.json"), + servermock.CheckRequestJSONBodyFromFixture("createResourceRecordSet-request.json"), + ). + Build(t) + + rrset := RRSet{ + Content: []string{"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, + Name: "_acme-challenge.example.com.", + TTL: 300, + Type: "TXT", + } + + result, err := client.CreateRRSet(t.Context(), "example.com", rrset) + require.NoError(t, err) + + expected := &RRSet{ + Content: []string{"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, + Name: "_acme-challenge.example.com.", + Editable: true, + TTL: 300, + Type: "TXT", + } + + assert.Equal(t, expected, result) +} + +func TestClient_GetRRSet(t *testing.T) { + client := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromFixture("getResourceRecordSet.json"), + ). + Build(t) + + result, err := client.GetRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") + require.NoError(t, err) + + expected := &RRSet{ + Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo"}, + Name: "_acme-challenge.example.com.", + Editable: true, + TTL: 3600, + Type: "TXT", + } + + assert.Equal(t, expected, result) +} + +func TestClient_GetRRSet_error_404(t *testing.T) { + client := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromFixture("error_404.json"). + WithStatusCode(http.StatusNotFound), + ). + Build(t) + + _, err := client.GetRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") + require.EqualError(t, err, "404: Resource not found (289346a1-3eaf-4da4-b707-62ef12eb08be)") + + target := &NotFoundError{} + require.ErrorAs(t, err, &target) +} + +func TestClient_UpdateRRSet(t *testing.T) { + client := mockBuilder(). + Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromFixture("updateResourceRecordSet.json"), + servermock.CheckRequestJSONBodyFromFixture("updateResourceRecordSet-request.json"), + ). + Build(t) + + rrset := RRSet{ + Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, + Name: "_acme-challenge.example.com.", + TTL: 3600, + Type: "TXT", + } + + result, err := client.UpdateRRSet(t.Context(), "example.com", rrset) + require.NoError(t, err) + + expected := &RRSet{ + Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, + Name: "_acme-challenge.example.com.", + Editable: true, + TTL: 3600, + Type: "TXT", + } + + assert.Equal(t, expected, result) +} + +func TestClient_DeleteRRSet(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + ). + Build(t) + + err := client.DeleteRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") + require.NoError(t, err) +} + +func TestClient_DeleteRRSet_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromFixture("error_401.json"). + WithStatusCode(http.StatusUnauthorized), + ). + Build(t) + + err := client.DeleteRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") + require.EqualError(t, err, "401: You are not authorized to view this resource. (289346a1-3eaf-4da4-b707-62ef12eb08be)") +} diff --git a/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet-request.json b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet-request.json new file mode 100644 index 000000000..af53fcf04 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet-request.json @@ -0,0 +1,8 @@ +{ + "content": [ + "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + ], + "name": "_acme-challenge.example.com.", + "ttl": 300, + "type": "TXT" +} diff --git a/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet.json b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet.json new file mode 100644 index 000000000..8ca040d63 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet.json @@ -0,0 +1,17 @@ +{ + "_links": { + "self": { + "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" + }, + "collection": { + "href": "/domains/example.com/resourceRecordSets" + } + }, + "content": [ + "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + ], + "editable": true, + "name": "_acme-challenge.example.com.", + "ttl": 300, + "type": "TXT" +} diff --git a/providers/dns/leaseweb/internal/fixtures/error_400.json b/providers/dns/leaseweb/internal/fixtures/error_400.json new file mode 100644 index 000000000..1a980b6bb --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/error_400.json @@ -0,0 +1,6 @@ +{ + "correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be", + "errorCode": "400", + "errorDetails": {}, + "errorMessage": "The API could not interpret your request correctly." +} diff --git a/providers/dns/leaseweb/internal/fixtures/error_401.json b/providers/dns/leaseweb/internal/fixtures/error_401.json new file mode 100644 index 000000000..47d8a311d --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/error_401.json @@ -0,0 +1,5 @@ +{ + "correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be", + "errorCode": "401", + "errorMessage": "You are not authorized to view this resource." +} diff --git a/providers/dns/leaseweb/internal/fixtures/error_404.json b/providers/dns/leaseweb/internal/fixtures/error_404.json new file mode 100644 index 000000000..1deaf5606 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/error_404.json @@ -0,0 +1,5 @@ +{ + "correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be", + "errorCode": "404", + "errorMessage": "Resource not found" +} diff --git a/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet.json b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet.json new file mode 100644 index 000000000..fd48f60c6 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet.json @@ -0,0 +1,18 @@ +{ + "_links": { + "self": { + "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" + }, + "collection": { + "href": "/domains/example.com/resourceRecordSets" + } + }, + "content": [ + "foo", + "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo" + ], + "editable": true, + "name": "_acme-challenge.example.com.", + "ttl": 3600, + "type": "TXT" +} diff --git a/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet2.json b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet2.json new file mode 100644 index 000000000..abf3fb4c3 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet2.json @@ -0,0 +1,17 @@ +{ + "_links": { + "self": { + "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" + }, + "collection": { + "href": "/domains/example.com/resourceRecordSets" + } + }, + "content": [ + "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo" + ], + "editable": true, + "name": "_acme-challenge.example.com.", + "ttl": 3600, + "type": "TXT" +} diff --git a/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request.json b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request.json new file mode 100644 index 000000000..e781958c8 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request.json @@ -0,0 +1,8 @@ +{ + "content": [ + "foo", + "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", + "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + ], + "ttl": 3600 +} diff --git a/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request2.json b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request2.json new file mode 100644 index 000000000..0acc314de --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request2.json @@ -0,0 +1,6 @@ +{ + "content": [ + "foo" + ], + "ttl": 3600 +} diff --git a/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet.json b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet.json new file mode 100644 index 000000000..2b877982c --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet.json @@ -0,0 +1,19 @@ +{ + "_links": { + "self": { + "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" + }, + "collection": { + "href": "/domains/example.com/resourceRecordSets" + } + }, + "content": [ + "foo", + "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", + "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + ], + "editable": true, + "name": "_acme-challenge.example.com.", + "ttl": 3600, + "type": "TXT" +} diff --git a/providers/dns/leaseweb/internal/types.go b/providers/dns/leaseweb/internal/types.go new file mode 100644 index 000000000..7a4547584 --- /dev/null +++ b/providers/dns/leaseweb/internal/types.go @@ -0,0 +1,35 @@ +package internal + +import ( + "encoding/json" + "fmt" +) + +type NotFoundError struct { + APIError +} + +type APIError struct { + CorrelationID string `json:"correlationId,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + ErrorDetails json.RawMessage `json:"errorDetails,omitempty"` +} + +func (a *APIError) Error() string { + msg := fmt.Sprintf("%s: %s (%s)", a.ErrorCode, a.ErrorMessage, a.CorrelationID) + + if len(a.ErrorDetails) > 0 { + msg += fmt.Sprintf(": %s", string(a.ErrorDetails)) + } + + return msg +} + +type RRSet struct { + Content []string `json:"content,omitempty"` + Name string `json:"name,omitempty"` + Editable bool `json:"editable,omitempty"` + TTL int `json:"ttl,omitempty"` + Type string `json:"type,omitempty"` +} diff --git a/providers/dns/leaseweb/leaseweb.go b/providers/dns/leaseweb/leaseweb.go new file mode 100644 index 000000000..fafaf1c4d --- /dev/null +++ b/providers/dns/leaseweb/leaseweb.go @@ -0,0 +1,187 @@ +// Package leaseweb implements a DNS provider for solving the DNS-01 challenge using Leaseweb. +package leaseweb + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/leaseweb/internal" +) + +// Environment variables names. +const ( + envNamespace = "LEASEWEB_" + + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIKey string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Leaseweb. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("leaseweb: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Leaseweb. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("leaseweb: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.APIKey) + if err != nil { + return nil, fmt.Errorf("leaseweb: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("leaseweb: could not find zone for domain %q: %w", domain, err) + } + + existingRRSet, err := d.client.GetRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT") + if err != nil { + notfoundErr := &internal.NotFoundError{} + if !errors.As(err, ¬foundErr) { + return fmt.Errorf("leaseweb: get RRSet: %w", err) + } + + // Create the RRSet. + + rrset := internal.RRSet{ + Content: []string{info.Value}, + Name: info.EffectiveFQDN, + TTL: internal.TTLRounder(d.config.TTL), + Type: "TXT", + } + + _, err = d.client.CreateRRSet(ctx, dns01.UnFqdn(authZone), rrset) + if err != nil { + return fmt.Errorf("leaseweb: create RRSet: %w", err) + } + + return nil + } + + // Update the RRSet. + + existingRRSet.Content = append(existingRRSet.Content, info.Value) + + _, err = d.client.UpdateRRSet(ctx, dns01.UnFqdn(authZone), *existingRRSet) + if err != nil { + return fmt.Errorf("leaseweb: update RRSet: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("leaseweb: could not find zone for domain %q: %w", domain, err) + } + + existingRRSet, err := d.client.GetRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT") + if err != nil { + return fmt.Errorf("leaseweb: get RRSet: %w", err) + } + + var content []string + + for _, s := range existingRRSet.Content { + if s != info.Value { + content = append(content, s) + } + } + + if len(content) == 0 { + err = d.client.DeleteRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT") + if err != nil { + return fmt.Errorf("leaseweb: delete RRSet: %w", err) + } + + return nil + } + + existingRRSet.Content = content + + _, err = d.client.UpdateRRSet(ctx, dns01.UnFqdn(authZone), *existingRRSet) + if err != nil { + return fmt.Errorf("leaseweb: update RRSet: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/leaseweb/leaseweb.toml b/providers/dns/leaseweb/leaseweb.toml new file mode 100644 index 000000000..2c3503291 --- /dev/null +++ b/providers/dns/leaseweb/leaseweb.toml @@ -0,0 +1,22 @@ +Name = "Leaseweb" +Description = '''''' +URL = "https://www.leaseweb.com/en/" +Code = "leaseweb" +Since = "v4.32.0" + +Example = ''' +LEASEWEB_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns leaseweb -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + LEASEWEB_API_KEY = "API key" + [Configuration.Additional] + LEASEWEB_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + LEASEWEB_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + LEASEWEB_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + LEASEWEB_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://developer.leaseweb.com/docs/#tag/DNS" diff --git a/providers/dns/leaseweb/leaseweb_test.go b/providers/dns/leaseweb/leaseweb_test.go new file mode 100644 index 000000000..0450cd2c2 --- /dev/null +++ b/providers/dns/leaseweb/leaseweb_test.go @@ -0,0 +1,204 @@ +package leaseweb + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/go-acme/lego/v4/providers/dns/leaseweb/internal" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "secret", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "leaseweb: some credentials information are missing: LEASEWEB_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + expected string + }{ + { + desc: "success", + apiKey: "secret", + }, + { + desc: "missing credentials", + expected: "leaseweb: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIKey = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With(internal.AuthHeader, "secret"), + ) +} + +func TestDNSProvider_Present_create(t *testing.T) { + provider := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("error_404.json"). + WithStatusCode(http.StatusNotFound), + ). + Route("POST /domains/example.com/resourceRecordSets", + servermock.ResponseFromInternal("createResourceRecordSet.json"), + servermock.CheckRequestJSONBodyFromInternal("createResourceRecordSet-request.json"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_Present_update(t *testing.T) { + provider := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("getResourceRecordSet.json"), + ). + Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("updateResourceRecordSet.json"), + servermock.CheckRequestJSONBodyFromInternal("updateResourceRecordSet-request.json"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp_delete(t *testing.T) { + provider := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("getResourceRecordSet2.json"), + ). + Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + ). + Build(t) + + err := provider.CleanUp("example.com", "abc", "1234d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp_update(t *testing.T) { + provider := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("getResourceRecordSet.json"), + ). + Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("updateResourceRecordSet.json"), + servermock.CheckRequestJSONBodyFromInternal("updateResourceRecordSet-request2.json"), + ). + Build(t) + + err := provider.CleanUp("example.com", "abc", "1234d==") + require.NoError(t, err) +} diff --git a/providers/dns/liara/internal/client.go b/providers/dns/liara/internal/client.go index 89794f04d..95c39695b 100644 --- a/providers/dns/liara/internal/client.go +++ b/providers/dns/liara/internal/client.go @@ -20,25 +20,31 @@ const defaultBaseURL = "https://dns-service.iran.liara.ir" type Client struct { baseURL *url.URL httpClient *http.Client + + teamID string } // NewClient creates a new Client. -func NewClient(hc *http.Client) *Client { +func NewClient(hc *http.Client, teamID string) *Client { baseURL, _ := url.Parse(defaultBaseURL) if hc == nil { hc = &http.Client{Timeout: 10 * time.Second} } - return &Client{httpClient: hc, baseURL: baseURL} + return &Client{ + httpClient: hc, + baseURL: baseURL, + teamID: teamID, + } } // GetRecords gets the records of a domain. -// https://dns-service.iran.liara.ir/swagger -func (c Client) GetRecords(ctx context.Context, domainName string) ([]Record, error) { +// https://openapi.liara.ir/?urls.primaryName=DNS +func (c *Client) GetRecords(ctx context.Context, domainName string) ([]Record, error) { endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records") - req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } @@ -60,6 +66,7 @@ func (c Client) GetRecords(ctx context.Context, domainName string) ([]Record, er } var response Response[[]Record] + err = json.Unmarshal(raw, &response) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) @@ -69,10 +76,10 @@ func (c Client) GetRecords(ctx context.Context, domainName string) ([]Record, er } // CreateRecord creates a record. -func (c Client) CreateRecord(ctx context.Context, domainName string, record Record) (*Record, error) { +func (c *Client) CreateRecord(ctx context.Context, domainName string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records") - req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + req, err := c.newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, fmt.Errorf("create request: %w", err) } @@ -94,6 +101,7 @@ func (c Client) CreateRecord(ctx context.Context, domainName string, record Reco } var response Response[*Record] + err = json.Unmarshal(raw, &response) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) @@ -103,10 +111,10 @@ func (c Client) CreateRecord(ctx context.Context, domainName string, record Reco } // GetRecord gets a specific record. -func (c Client) GetRecord(ctx context.Context, domainName, recordID string) (*Record, error) { +func (c *Client) GetRecord(ctx context.Context, domainName, recordID string) (*Record, error) { endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID) - req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } @@ -128,6 +136,7 @@ func (c Client) GetRecord(ctx context.Context, domainName, recordID string) (*Re } var response Response[*Record] + err = json.Unmarshal(raw, &response) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) @@ -137,10 +146,10 @@ func (c Client) GetRecord(ctx context.Context, domainName, recordID string) (*Re } // DeleteRecord deletes a record. -func (c Client) DeleteRecord(ctx context.Context, domainName, recordID string) error { +func (c *Client) DeleteRecord(ctx context.Context, domainName, recordID string) error { endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID) - req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + req, err := c.newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return fmt.Errorf("create request: %w", err) } @@ -159,7 +168,14 @@ func (c Client) DeleteRecord(ctx context.Context, domainName, recordID string) e return nil } -func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { +func (c *Client) newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + if c.teamID != "" { + query := endpoint.Query() + query.Set("teamID", c.teamID) + + endpoint.RawQuery = query.Encode() + } + buf := new(bytes.Buffer) if payload != nil { @@ -187,6 +203,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError + err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/liara/internal/client_test.go b/providers/dns/liara/internal/client_test.go index ed6672ab6..b6d007046 100644 --- a/providers/dns/liara/internal/client_test.go +++ b/providers/dns/liara/internal/client_test.go @@ -1,28 +1,36 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const apiKey = "key" +func mockBuilder(teamID string) *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey), teamID) + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer "+apiKey)) +} + func TestClient_GetRecords(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(""). + Route("GET /api/v1/zones/example.com/dns-records", servermock.ResponseFromFixture("RecordsResponse.json")). + Build(t) - mux.HandleFunc("/api/v1/zones/example.com/dns-records", testHandler("./RecordsResponse.json", http.MethodGet, http.StatusOK)) - - records, err := client.GetRecords(context.Background(), "example.com") + records, err := client.GetRecords(t.Context(), "example.com") require.NoError(t, err) expected := []Record{ @@ -42,11 +50,11 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(""). + Route("GET /api/v1/zones/example.com/dns-records/123", servermock.ResponseFromFixture("RecordResponse.json")). + Build(t) - mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", testHandler("./RecordResponse.json", http.MethodGet, http.StatusOK)) - - record, err := client.GetRecord(context.Background(), "example.com", "123") + record, err := client.GetRecord(t.Context(), "example.com", "123") require.NoError(t, err) expected := &Record{ @@ -64,9 +72,12 @@ func TestClient_GetRecord(t *testing.T) { } func TestClient_CreateRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/api/v1/zones/example.com/dns-records", testHandler("./RecordResponse.json", http.MethodPost, http.StatusCreated)) + client := mockBuilder(""). + Route("POST /api/v1/zones/example.com/dns-records", + servermock.ResponseFromFixture("RecordResponse.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBody(`{"name":"string","type":"string","ttl":3600,"contents":[{"text":"string"}]}`)). + Build(t) data := Record{ Type: "string", @@ -79,7 +90,46 @@ func TestClient_CreateRecord(t *testing.T) { TTL: 3600, } - record, err := client.CreateRecord(context.Background(), "example.com", data) + record, err := client.CreateRecord(t.Context(), "example.com", data) + require.NoError(t, err) + + expected := &Record{ + ID: "string", + Type: "string", + Name: "string", + Contents: []Content{ + { + Text: "string", + }, + }, + TTL: 3600, + } + + assert.Equal(t, expected, record) +} + +func TestClient_CreateRecord_withTeamID(t *testing.T) { + client := mockBuilder("123"). + Route("POST /api/v1/zones/example.com/dns-records", + servermock.ResponseFromFixture("RecordResponse.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBody(`{"name":"string","type":"string","ttl":3600,"contents":[{"text":"string"}]}`), + servermock.CheckQueryParameter().Strict().With("teamID", "123"), + ). + Build(t) + + data := Record{ + Type: "string", + Name: "string", + Contents: []Content{ + { + Text: "string", + }, + }, + TTL: 3600, + } + + record, err := client.CreateRecord(t.Context(), "example.com", data) require.NoError(t, err) expected := &Record{ @@ -98,76 +148,34 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(""). + Route("DELETE /api/v1/zones/example.com/dns-records/123", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) - mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", func(rw http.ResponseWriter, req *http.Request) { - rw.WriteHeader(http.StatusNoContent) - }) - - err := client.DeleteRecord(context.Background(), "example.com", "123") + err := client.DeleteRecord(t.Context(), "example.com", "123") require.NoError(t, err) } func TestClient_DeleteRecord_NotFound_Response(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(""). + Route("DELETE /api/v1/zones/example.com/dns-records/123", + servermock.Noop(). + WithStatusCode(http.StatusNotFound)). + Build(t) - mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", func(rw http.ResponseWriter, req *http.Request) { - rw.WriteHeader(http.StatusNotFound) - }) - - err := client.DeleteRecord(context.Background(), "example.com", "123") + err := client.DeleteRecord(t.Context(), "example.com", "123") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(""). + Route("DELETE /api/v1/zones/example.com/dns-records/123", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", testHandler("./error.json", http.MethodDelete, http.StatusUnauthorized)) - - err := client.DeleteRecord(context.Background(), "example.com", "123") - require.Error(t, err) -} - -func testHandler(filename string, method string, statusCode int) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Bearer "+apiKey { - http.Error(rw, "invalid Authorization header", http.StatusUnauthorized) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} - -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey)) - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + err := client.DeleteRecord(t.Context(), "example.com", "123") + require.EqualError(t, err, "[status code: 401] Unauthorized: Invalid token missing header") } diff --git a/providers/dns/liara/liara.go b/providers/dns/liara/liara.go index cb4ab7c8d..c7e403eed 100644 --- a/providers/dns/liara/liara.go +++ b/providers/dns/liara/liara.go @@ -9,23 +9,21 @@ import ( "sync" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/liara/internal" "github.com/hashicorp/go-retryablehttp" ) -const ( - minTTL = 120 - maxTTL = 432000 -) - // Environment variables names. const ( envNamespace = "LIARA_" EnvAPIKey = envNamespace + "API_KEY" + EnvTeamID = envNamespace + "TEAM_ID" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" @@ -33,9 +31,18 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const ( + minTTL = 120 + maxTTL = 432000 +) + +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 @@ -73,6 +80,7 @@ func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] + config.TeamID = env.GetOrFile(EnvTeamID) return NewDNSProviderConfig(config) } @@ -96,13 +104,20 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { } retryClient := retryablehttp.NewClient() + retryClient.RetryMax = 5 if config.HTTPClient != nil { retryClient.HTTPClient = config.HTTPClient } + retryClient.Logger = log.Logger - client := internal.NewClient(internal.OAuthStaticAccessToken(retryClient.StandardClient(), config.APIKey)) + client := internal.NewClient( + clientdebug.Wrap( + internal.OAuthStaticAccessToken(retryClient.StandardClient(), config.APIKey), + ), + config.TeamID, + ) return &DNSProvider{ config: config, @@ -137,6 +152,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { Contents: []internal.Content{{Text: info.Value}}, TTL: d.config.TTL, } + newRecord, err := d.client.CreateRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("liara: failed to create TXT record, fqdn=%s: %w", info.EffectiveFQDN, err) @@ -162,6 +178,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("liara: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } diff --git a/providers/dns/liara/liara.toml b/providers/dns/liara/liara.toml index aaa4061f5..f471de04e 100644 --- a/providers/dns/liara/liara.toml +++ b/providers/dns/liara/liara.toml @@ -6,17 +6,18 @@ Since = "v4.10.0" Example = ''' LIARA_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ -lego --email you@example.com --dns liara -d '*.example.com' -d example.com run +lego --dns liara -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] LIARA_API_KEY = "The API key" [Configuration.Additional] - LIARA_POLLING_INTERVAL = "Time between DNS propagation check" - LIARA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - LIARA_TTL = "The TTL of the TXT record used for the DNS challenge" - LIARA_HTTP_TIMEOUT = "API request timeout" + LIARA_TEAM_ID = "The team ID to access services in a team" + LIARA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + LIARA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + LIARA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" + LIARA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] - API = "https://dns-service.iran.liara.ir/swagger" + API = "https://openapi.liara.ir/?urls.primaryName=DNS" diff --git a/providers/dns/liara/liara_test.go b/providers/dns/liara/liara_test.go index 4256be55e..b1f3f77c9 100644 --- a/providers/dns/liara/liara_test.go +++ b/providers/dns/liara/liara_test.go @@ -38,6 +38,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -113,6 +114,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -126,6 +128,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/lightsail/lightsail.go b/providers/dns/lightsail/lightsail.go index 125b1aa61..95b07c503 100644 --- a/providers/dns/lightsail/lightsail.go +++ b/providers/dns/lightsail/lightsail.go @@ -14,14 +14,11 @@ import ( awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/lightsail" awstypes "github.com/aws/aws-sdk-go-v2/service/lightsail/types" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" ) -const ( - maxRetries = 5 -) - // Environment variables names. const ( envNamespace = "LIGHTSAIL_" @@ -33,6 +30,10 @@ const ( EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +const maxRetries = 5 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { DNSZone string @@ -95,12 +96,10 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { // causing a high number of consecutive throttling errors. // For reference: Route 53 enforces an account-wide(!) 5req/s query limit. options.Backoff = retry.BackoffDelayerFunc(func(attempt int, err error) (time.Duration, error) { - retryCount := attempt - if retryCount > 7 { - retryCount = 7 - } + retryCount := min(attempt, 7) delay := (1 << uint(retryCount)) * (rand.Intn(50) + 200) + return time.Duration(delay) * time.Millisecond, nil }) }) diff --git a/providers/dns/lightsail/lightsail.toml b/providers/dns/lightsail/lightsail.toml index 4ade894d1..47b212f62 100644 --- a/providers/dns/lightsail/lightsail.toml +++ b/providers/dns/lightsail/lightsail.toml @@ -52,8 +52,8 @@ Alternatively, you can also set the `Resource` to `*` (wildcard), which allow to DNS_ZONE = "Domain name of the DNS zone" [Configuration.Additional] AWS_SHARED_CREDENTIALS_FILE = "Managed by the AWS client. Shared credentials file." - LIGHTSAIL_POLLING_INTERVAL = "Time between DNS propagation check" - LIGHTSAIL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + LIGHTSAIL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + LIGHTSAIL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" [Links] GoClient = "https://github.com/aws/aws-sdk-go-v2" diff --git a/providers/dns/lightsail/lightsail_integration_test.go b/providers/dns/lightsail/lightsail_integration_test.go index 20e45ee26..dc86bf079 100644 --- a/providers/dns/lightsail/lightsail_integration_test.go +++ b/providers/dns/lightsail/lightsail_integration_test.go @@ -1,12 +1,12 @@ package lightsail import ( - "context" "testing" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/lightsail" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" "github.com/stretchr/testify/require" ) @@ -28,12 +28,13 @@ func TestLiveTTL(t *testing.T) { // we need a separate Lightsail client here as the one in the DNS provider is unexported. fqdn := "_acme-challenge." + domain - ctx := context.Background() + ctx := t.Context() cfg, err := awsconfig.LoadDefaultConfig(ctx) require.NoError(t, err) svc := lightsail.NewFromConfig(cfg) + require.NoError(t, err) defer func() { @@ -52,19 +53,10 @@ func TestLiveTTL(t *testing.T) { entries := resp.Domain.DomainEntries for _, entry := range entries { - if deref(entry.Type) == "TXT" && deref(entry.Name) == fqdn { + if ptr.Deref(entry.Type) == "TXT" && ptr.Deref(entry.Name) == fqdn { return } } t.Fatalf("Could not find a TXT record for _acme-challenge.%s", domain) } - -func deref[T string | int | int32 | int64 | bool](v *T) T { - if v == nil { - var zero T - return zero - } - - return *v -} diff --git a/providers/dns/lightsail/lightsail_test.go b/providers/dns/lightsail/lightsail_test.go index 14370ffd9..a6b46045e 100644 --- a/providers/dns/lightsail/lightsail_test.go +++ b/providers/dns/lightsail/lightsail_test.go @@ -1,7 +1,7 @@ package lightsail import ( - "context" + "net/http/httptest" "os" "testing" @@ -10,6 +10,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/lightsail" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,29 +32,16 @@ var envTest = tester.NewEnvTest( WithDomain(EnvDNSZone). WithLiveTestRequirements(envAwsAccessKeyID, envAwsSecretAccessKey, EnvDNSZone) -func makeProvider(serverURL string) *DNSProvider { - config := aws.Config{ - Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "), - Region: "mock-region", - BaseEndpoint: aws.String(serverURL), - RetryMaxAttempts: 1, - } - - return &DNSProvider{ - client: lightsail.NewFromConfig(config), - config: NewDefaultConfig(), - } -} - func TestCredentialsFromEnv(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() _ = os.Setenv(envAwsAccessKeyID, "123") _ = os.Setenv(envAwsSecretAccessKey, "123") _ = os.Setenv(envAwsRegion, "us-east-1") - ctx := context.Background() + ctx := t.Context() cfg, err := awsconfig.LoadDefaultConfig(ctx) require.NoError(t, err) @@ -69,17 +57,25 @@ func TestCredentialsFromEnv(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - mockResponses := map[string]MockResponse{ - "/": {StatusCode: 200, Body: ""}, - } - - serverURL := newMockServer(t, mockResponses) - - provider := makeProvider(serverURL) + provider := servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + return &DNSProvider{ + client: lightsail.NewFromConfig(aws.Config{ + HTTPClient: server.Client(), + Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "), + Region: "mock-region", + BaseEndpoint: aws.String(server.URL), + RetryMaxAttempts: 1, + }), + config: NewDefaultConfig(), + }, nil + }). + Route("POST /", nil). + Build(t) domain := "example.com" keyAuth := "123456d==" err := provider.Present(domain, "", keyAuth) - require.NoError(t, err, "Expected Present to return no error") + require.NoError(t, err) } diff --git a/providers/dns/lightsail/mock_server_test.go b/providers/dns/lightsail/mock_server_test.go deleted file mode 100644 index 385c80850..000000000 --- a/providers/dns/lightsail/mock_server_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package lightsail - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// MockResponse represents a predefined response used by a mock server. -type MockResponse struct { - StatusCode int - Body string -} - -func newMockServer(t *testing.T, responses map[string]MockResponse) string { - t.Helper() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - resp, ok := responses[path] - if !ok { - msg := fmt.Sprintf("Requested path not found in response map: %s", path) - require.FailNow(t, msg) - } - - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(resp.StatusCode) - _, err := w.Write([]byte(resp.Body)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - })) - - t.Cleanup(server.Close) - - time.Sleep(100 * time.Millisecond) - - return server.URL -} diff --git a/providers/dns/limacity/internal/client.go b/providers/dns/limacity/internal/client.go index 8a8b93adb..ae6ab87eb 100644 --- a/providers/dns/limacity/internal/client.go +++ b/providers/dns/limacity/internal/client.go @@ -32,7 +32,7 @@ func NewClient(apiKey string) *Client { } } -func (c Client) GetDomains(ctx context.Context) ([]Domain, error) { +func (c *Client) GetDomains(ctx context.Context) ([]Domain, error) { endpoint := c.baseURL.JoinPath("domains.json") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -41,6 +41,7 @@ func (c Client) GetDomains(ctx context.Context) ([]Domain, error) { } var results DomainsResponse + err = c.do(req, &results) if err != nil { return nil, err @@ -49,7 +50,7 @@ func (c Client) GetDomains(ctx context.Context) ([]Domain, error) { return results.Data, nil } -func (c Client) GetRecords(ctx context.Context, domainID int) ([]Record, error) { +func (c *Client) GetRecords(ctx context.Context, domainID int) ([]Record, error) { endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domainID), "records.json") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -58,6 +59,7 @@ func (c Client) GetRecords(ctx context.Context, domainID int) ([]Record, error) } var results RecordsResponse + err = c.do(req, &results) if err != nil { return nil, err @@ -66,7 +68,7 @@ func (c Client) GetRecords(ctx context.Context, domainID int) ([]Record, error) return results.Data, nil } -func (c Client) AddRecord(ctx context.Context, domainID int, record Record) error { +func (c *Client) AddRecord(ctx context.Context, domainID int, record Record) error { endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domainID), "records.json") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, NameserverRecordPayload{Data: record}) @@ -75,6 +77,7 @@ func (c Client) AddRecord(ctx context.Context, domainID int, record Record) erro } var results APIResponse + err = c.do(req, &results) if err != nil { return err @@ -83,7 +86,7 @@ func (c Client) AddRecord(ctx context.Context, domainID int, record Record) erro return nil } -func (c Client) UpdateRecord(ctx context.Context, domainID, recordID int, record Record) error { +func (c *Client) UpdateRecord(ctx context.Context, domainID, recordID int, record Record) error { endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domainID), "records", strconv.Itoa(recordID)) req, err := newJSONRequest(ctx, http.MethodPut, endpoint, NameserverRecordPayload{Data: record}) @@ -92,6 +95,7 @@ func (c Client) UpdateRecord(ctx context.Context, domainID, recordID int, record } var results APIResponse + err = c.do(req, &results) if err != nil { return err @@ -100,7 +104,7 @@ func (c Client) UpdateRecord(ctx context.Context, domainID, recordID int, record return nil } -func (c Client) DeleteRecord(ctx context.Context, domainID, recordID int) error { +func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int) error { // /domains/{domainId}/records/{recordId} DELETE endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domainID), "records", strconv.Itoa(recordID)) @@ -110,6 +114,7 @@ func (c Client) DeleteRecord(ctx context.Context, domainID, recordID int) error } var results APIResponse + err = c.do(req, &results) if err != nil { return err @@ -118,7 +123,7 @@ func (c Client) DeleteRecord(ctx context.Context, domainID, recordID int) error return nil } -func (c Client) do(req *http.Request, result any) error { +func (c *Client) do(req *http.Request, result any) error { req.SetBasicAuth("api", c.apiKey) resp, err := c.HTTPClient.Do(req) @@ -177,6 +182,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIResponse + err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/limacity/internal/client_test.go b/providers/dns/limacity/internal/client_test.go index b9a13bdab..c43f12ba2 100644 --- a/providers/dns/limacity/internal/client_test.go +++ b/providers/dns/limacity/internal/client_test.go @@ -1,72 +1,38 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const apiKey = "secret" -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(apiKey) + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(apiKey) - client.baseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func testHandler(filename string, method string, statusCode int) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - username, key, ok := req.BasicAuth() - if username != "api" || key != apiKey || !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("api", apiKey), + ) } func TestClient_GetDomains(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /domains.json", servermock.ResponseFromFixture("get-domains.json")). + Build(t) - mux.HandleFunc("/domains.json", testHandler("get-domains.json", http.MethodGet, http.StatusOK)) - - domains, err := client.GetDomains(context.Background()) + domains, err := client.GetDomains(t.Context()) require.NoError(t, err) expected := []Domain{{ @@ -80,20 +46,22 @@ func TestClient_GetDomains(t *testing.T) { } func TestClient_GetDomains_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /domains.json", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) - mux.HandleFunc("/domains.json", testHandler("error.json", http.MethodGet, http.StatusBadRequest)) - - _, err := client.GetDomains(context.Background()) + _, err := client.GetDomains(t.Context()) require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]") } func TestClient_GetRecords(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /domains/123/records.json", servermock.ResponseFromFixture("get-records.json")). + Build(t) - mux.HandleFunc("/domains/123/records.json", testHandler("get-records.json", http.MethodGet, http.StatusOK)) - - records, err := client.GetRecords(context.Background(), 123) + records, err := client.GetRecords(t.Context(), 123) require.NoError(t, err) expected := []Record{ @@ -116,18 +84,22 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecords_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /domains/123/records.json", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) - mux.HandleFunc("/domains/123/records.json", testHandler("error.json", http.MethodGet, http.StatusBadRequest)) - - _, err := client.GetRecords(context.Background(), 123) + _, err := client.GetRecords(t.Context(), 123) require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]") } func TestClient_AddRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/123/records.json", testHandler("ok.json", http.MethodPost, http.StatusOK)) + client := mockBuilder(). + Route("POST /domains/123/records.json", + servermock.ResponseFromFixture("ok.json"), + servermock.CheckRequestJSONBody(`{"nameserver_record":{"name":"foo","content":"bar","ttl":12,"type":"TXT"}}`)). + Build(t) record := Record{ Name: "foo", @@ -136,14 +108,16 @@ func TestClient_AddRecord(t *testing.T) { Type: "TXT", } - err := client.AddRecord(context.Background(), 123, record) + err := client.AddRecord(t.Context(), 123, record) require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/123/records.json", testHandler("error.json", http.MethodPost, http.StatusBadRequest)) + client := mockBuilder(). + Route("POST /domains/123/records.json", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) record := Record{ Name: "foo", @@ -152,42 +126,49 @@ func TestClient_AddRecord_error(t *testing.T) { Type: "TXT", } - err := client.AddRecord(context.Background(), 123, record) + err := client.AddRecord(t.Context(), 123, record) require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]") } func TestClient_UpdateRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("PUT /domains/123/records/456", + servermock.ResponseFromFixture("ok.json"), + servermock.CheckRequestJSONBody(`{"nameserver_record":{}}`)). + Build(t) - mux.HandleFunc("/domains/123/records/456", testHandler("ok.json", http.MethodPut, http.StatusOK)) - - err := client.UpdateRecord(context.Background(), 123, 456, Record{}) + err := client.UpdateRecord(t.Context(), 123, 456, Record{}) require.NoError(t, err) } func TestClient_UpdateRecord_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("PUT /domains/123/records/456", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) - mux.HandleFunc("/domains/123/records/456", testHandler("error.json", http.MethodPut, http.StatusBadRequest)) - - err := client.UpdateRecord(context.Background(), 123, 456, Record{}) + err := client.UpdateRecord(t.Context(), 123, 456, Record{}) require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]") } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /domains/123/records/456", + servermock.ResponseFromFixture("ok.json")). + Build(t) - mux.HandleFunc("/domains/123/records/456", testHandler("ok.json", http.MethodDelete, http.StatusOK)) - - err := client.DeleteRecord(context.Background(), 123, 456) + err := client.DeleteRecord(t.Context(), 123, 456) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /domains/123/records/456", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) - mux.HandleFunc("/domains/123/records/456", testHandler("error.json", http.MethodDelete, http.StatusBadRequest)) - - err := client.DeleteRecord(context.Background(), 123, 456) + err := client.DeleteRecord(t.Context(), 123, 456) require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]") } diff --git a/providers/dns/limacity/internal/types.go b/providers/dns/limacity/internal/types.go index 5fdbacef9..7411632ea 100644 --- a/providers/dns/limacity/internal/types.go +++ b/providers/dns/limacity/internal/types.go @@ -10,7 +10,7 @@ type RecordsResponse struct { } type NameserverRecordPayload struct { - Data Record `json:"nameserver_record,omitempty"` + Data Record `json:"nameserver_record"` } type DomainsResponse struct { diff --git a/providers/dns/limacity/limacity.go b/providers/dns/limacity/limacity.go index a999f5648..3291faf66 100644 --- a/providers/dns/limacity/limacity.go +++ b/providers/dns/limacity/limacity.go @@ -10,10 +10,11 @@ import ( "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/limacity/internal" - "github.com/miekg/dns" ) // Environment variables names. @@ -29,6 +30,8 @@ const ( EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -87,6 +90,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client := internal.NewClient(config.APIKey) + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -108,9 +117,11 @@ func (d *DNSProvider) Sequential() time.Duration { // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) - domains, err := d.client.GetDomains(context.Background()) + domains, err := d.client.GetDomains(ctx) if err != nil { return fmt.Errorf("limacity: get domains: %w", err) } @@ -132,7 +143,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { Type: "TXT", } - err = d.client.AddRecord(context.Background(), dom.ID, record) + err = d.client.AddRecord(ctx, dom.ID, record) if err != nil { return fmt.Errorf("limacity: add record: %w", err) } @@ -146,22 +157,26 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // CleanUp removes the TXT record. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) // gets the domain's unique ID d.domainIDsMu.Lock() domainID, ok := d.domainIDs[token] d.domainIDsMu.Unlock() + if !ok { return fmt.Errorf("limacity: unknown domain ID for '%s' '%s'", info.EffectiveFQDN, token) } - records, err := d.client.GetRecords(context.Background(), domainID) + records, err := d.client.GetRecords(ctx, domainID) if err != nil { return fmt.Errorf("limacity: get records: %w", err) } var recordID int + for _, record := range records { if record.Type == "TXT" && record.Content == strconv.Quote(info.Value) { recordID = record.ID @@ -173,19 +188,20 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return errors.New("limacity: TXT record not found") } - err = d.client.DeleteRecord(context.Background(), domainID, recordID) + err = d.client.DeleteRecord(ctx, domainID, recordID) if err != nil { return fmt.Errorf("limacity: delete record (domain ID=%d, record ID=%d): %w", domainID, recordID, err) } + d.domainIDsMu.Lock() + delete(d.domainIDs, info.EffectiveFQDN) + d.domainIDsMu.Unlock() + return nil } func findDomain(domains []internal.Domain, fqdn string) (internal.Domain, error) { - labelIndexes := dns.Split(fqdn) - - for _, index := range labelIndexes { - f := fqdn[index:] + for f := range dns01.DomainsSeq(fqdn) { domain := dns01.UnFqdn(f) for _, dom := range domains { diff --git a/providers/dns/limacity/limacity.toml b/providers/dns/limacity/limacity.toml index c9bcaf16e..d236577d0 100644 --- a/providers/dns/limacity/limacity.toml +++ b/providers/dns/limacity/limacity.toml @@ -6,18 +6,18 @@ Since = "v4.18.0" Example = ''' LIMACITY_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ -lego --email you@example.com --dns limacity -d '*.example.com' -d example.com run +lego --dns limacity -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] LIMACITY_API_KEY = "The API key" [Configuration.Additional] - LIMACITY_POLLING_INTERVAL = "Time between DNS propagation check" - LIMACITY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - LIMACITY_SEQUENCE_INTERVAL = "Time between sequential requests" - LIMACITY_TTL = "The TTL of the TXT record used for the DNS challenge" - LIMACITY_HTTP_TIMEOUT = "API request timeout" + LIMACITY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 80)" + LIMACITY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 480)" + LIMACITY_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 90)" + LIMACITY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + LIMACITY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.lima-city.de/hilfe/lima-city-api" diff --git a/providers/dns/limacity/limacity_test.go b/providers/dns/limacity/limacity_test.go index 2834a5f1f..3301fcb2e 100644 --- a/providers/dns/limacity/limacity_test.go +++ b/providers/dns/limacity/limacity_test.go @@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -92,6 +93,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -105,6 +107,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/linode/linode.go b/providers/dns/linode/linode.go index f9d77ebcf..b03dee4f5 100644 --- a/providers/dns/linode/linode.go +++ b/providers/dns/linode/linode.go @@ -9,19 +9,15 @@ import ( "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/internal/useragent" "github.com/linode/linodego" "golang.org/x/oauth2" ) -const ( - minTTL = 300 - dnsUpdateFreqMins = 15 - dnsUpdateFudgeSecs = 120 -) - // Environment variables names. const ( envNamespace = "LINODE_" @@ -34,6 +30,14 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const ( + minTTL = 300 + dnsUpdateFreqMins = 15 + dnsUpdateFudgeSecs = 120 +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Token string @@ -47,9 +51,9 @@ type Config struct { func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 0), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 15*time.Second), - HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 0), + HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), } } @@ -99,7 +103,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { }, } - client := linodego.NewClient(oauth2Client) + client := linodego.NewClient(clientdebug.Wrap(oauth2Client)) client.SetUserAgent(useragent.Get()) return &DNSProvider{config: config, client: &client}, nil @@ -127,9 +131,11 @@ func (d *DNSProvider) Timeout() (time.Duration, time.Duration) { // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) - zone, err := d.getHostedZoneInfo(info.EffectiveFQDN) + zone, err := d.getHostedZoneInfo(ctx, info.EffectiveFQDN) if err != nil { return err } @@ -141,22 +147,26 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { Type: linodego.RecordTypeTXT, } - _, err = d.client.CreateDomainRecord(context.Background(), zone.domainID, createOpts) + _, err = d.client.CreateDomainRecord(ctx, zone.domainID, createOpts) + return err } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) - zone, err := d.getHostedZoneInfo(info.EffectiveFQDN) + zone, err := d.getHostedZoneInfo(ctx, info.EffectiveFQDN) if err != nil { return err } // Get all TXT records for the specified domain. listOpts := linodego.NewListOptions(0, `{"type":"TXT"}`) - resources, err := d.client.ListDomainRecords(context.Background(), zone.domainID, listOpts) + + resources, err := d.client.ListDomainRecords(ctx, zone.domainID, listOpts) if err != nil { return err } @@ -165,7 +175,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { for _, resource := range resources { if (resource.Name == dns01.UnFqdn(info.EffectiveFQDN) || resource.Name == zone.resourceName) && resource.Target == info.Value { - if err := d.client.DeleteDomainRecord(context.Background(), zone.domainID, resource.ID); err != nil { + if err := d.client.DeleteDomainRecord(ctx, zone.domainID, resource.ID); err != nil { return err } } @@ -174,7 +184,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } -func (d *DNSProvider) getHostedZoneInfo(fqdn string) (*hostedZoneInfo, error) { +func (d *DNSProvider) getHostedZoneInfo(ctx context.Context, fqdn string) (*hostedZoneInfo, error) { // Lookup the zone that handles the specified FQDN. authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { @@ -188,7 +198,8 @@ func (d *DNSProvider) getHostedZoneInfo(fqdn string) (*hostedZoneInfo, error) { } listOpts := linodego.NewListOptions(0, string(filter)) - domains, err := d.client.ListDomains(context.Background(), listOpts) + + domains, err := d.client.ListDomains(ctx, listOpts) if err != nil { return nil, err } diff --git a/providers/dns/linode/linode.toml b/providers/dns/linode/linode.toml index 790a2238c..9ea30b92b 100644 --- a/providers/dns/linode/linode.toml +++ b/providers/dns/linode/linode.toml @@ -7,17 +7,17 @@ Since = "v1.1.0" Example = ''' LINODE_TOKEN=xxxxx \ -lego --email you@example.com --dns linode -d '*.example.com' -d example.com run +lego --dns linode -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] LINODE_TOKEN = "API token" [Configuration.Additional] - LINODE_POLLING_INTERVAL = "Time between DNS propagation check" - LINODE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - LINODE_TTL = "The TTL of the TXT record used for the DNS challenge" - LINODE_HTTP_TIMEOUT = "API request timeout" + LINODE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 15)" + LINODE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + LINODE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + LINODE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.linode.com/api/v4" diff --git a/providers/dns/linode/linode_test.go b/providers/dns/linode/linode_test.go index 70b33eda4..1c4903aca 100644 --- a/providers/dns/linode/linode_test.go +++ b/providers/dns/linode/linode_test.go @@ -1,69 +1,20 @@ package linode import ( - "encoding/json" - "fmt" "net/http" "net/http/httptest" "os" "testing" - "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/linode/linodego" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -type MockResponseMap map[string]interface{} - var envTest = tester.NewEnvTest(EnvToken) -func setupTest(t *testing.T, responses MockResponseMap) string { - t.Helper() - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Ensure that we support the requested action. - action := r.Method + ":" + r.URL.Path - resp, ok := responses[action] - if !ok { - http.Error(w, fmt.Sprintf("Unsupported mock action: %q", action), http.StatusInternalServerError) - return - } - - rawResponse, err := json.Marshal(resp) - if err != nil { - http.Error(w, fmt.Sprintf("Failed to JSON encode response: %v", err), http.StatusInternalServerError) - return - } - - // Send the response. - w.Header().Set("Content-Type", "application/json") - if err, ok := resp.(linodego.APIError); ok { - if err.Errors[0].Reason == "Not found" { - w.WriteHeader(http.StatusNotFound) - } else { - w.WriteHeader(http.StatusBadRequest) - } - } else { - w.WriteHeader(http.StatusOK) - } - - _, err = w.Write(rawResponse) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - time.Sleep(100 * time.Millisecond) - - return server.URL -} - func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string @@ -88,6 +39,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -143,83 +95,80 @@ func TestNewDNSProviderConfig(t *testing.T) { func TestDNSProvider_Present(t *testing.T) { defer envTest.RestoreEnv() - os.Setenv(EnvToken, "testing") - p, err := NewDNSProvider() - require.NoError(t, err) - require.NotNil(t, p) + os.Setenv(EnvToken, "testing") domain := "example.com" keyAuth := "dGVzdGluZw==" testCases := []struct { desc string - mockResponses MockResponseMap + builder *servermock.Builder[*DNSProvider] expectedError string }{ { desc: "Success", - mockResponses: MockResponseMap{ - "GET:/v4/domains": linodego.DomainsPagedResponse{ - PageOptions: &linodego.PageOptions{ - Pages: 1, - Results: 1, - Page: 1, - }, - Data: []linodego.Domain{{ - Domain: domain, - ID: 1234, - }}, - }, - "POST:/v4/domains/1234/records": linodego.DomainRecord{ + builder: mockBuilder(). + Route("GET /v4/domains", + servermock.JSONEncode(linodego.DomainsPagedResponse{ + PageOptions: &linodego.PageOptions{ + Pages: 1, + Results: 1, + Page: 1, + }, + Data: []linodego.Domain{{ + Domain: domain, + ID: 1234, + }}, + })). + Route("POST /v4/domains/1234/records", servermock.JSONEncode(linodego.DomainRecord{ ID: 1234, - }, - }, + })), }, { desc: "NoDomain", - mockResponses: MockResponseMap{ - "GET:/v4/domains": linodego.APIError{ - Errors: []linodego.APIErrorReason{{ - Reason: "Not found", - }}, - }, - }, + builder: mockBuilder(). + Route("GET /v4/domains", + servermock.JSONEncode(linodego.APIError{ + Errors: []linodego.APIErrorReason{{ + Reason: "Not found", + }}, + }). + WithStatusCode(http.StatusNotFound)), expectedError: "[404] Not found", }, { desc: "CreateFailed", - mockResponses: MockResponseMap{ - "GET:/v4/domains": &linodego.DomainsPagedResponse{ - PageOptions: &linodego.PageOptions{ - Pages: 1, - Results: 1, - Page: 1, - }, - Data: []linodego.Domain{{ - Domain: "example.com", - ID: 1234, - }}, - }, - "POST:/v4/domains/1234/records": linodego.APIError{ - Errors: []linodego.APIErrorReason{{ - Reason: "Failed to create domain resource", - Field: "somefield", - }}, - }, - }, + builder: mockBuilder(). + Route("GET /v4/domains", + servermock.JSONEncode(&linodego.DomainsPagedResponse{ + PageOptions: &linodego.PageOptions{ + Pages: 1, + Results: 1, + Page: 1, + }, + Data: []linodego.Domain{{ + Domain: "example.com", + ID: 1234, + }}, + })). + Route("POST /v4/domains/1234/records", + servermock.JSONEncode(linodego.APIError{ + Errors: []linodego.APIErrorReason{{ + Reason: "Failed to create domain resource", + Field: "somefield", + }}, + }). + WithStatusCode(http.StatusBadRequest)), expectedError: "[400] [somefield] Failed to create domain resource", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - serverURL := setupTest(t, test.mockResponses) + provider := test.builder.Build(t) - assert.NotNil(t, p.client) - p.client.SetBaseURL(serverURL) - - err = p.Present(domain, "", keyAuth) + err := provider.Present(domain, "", keyAuth) if test.expectedError == "" { assert.NoError(t, err) } else { @@ -231,109 +180,114 @@ func TestDNSProvider_Present(t *testing.T) { func TestDNSProvider_CleanUp(t *testing.T) { defer envTest.RestoreEnv() - os.Setenv(EnvToken, "testing") - p, err := NewDNSProvider() - require.NoError(t, err) + os.Setenv(EnvToken, "testing") domain := "example.com" keyAuth := "dGVzdGluZw==" testCases := []struct { desc string - mockResponses MockResponseMap + builder *servermock.Builder[*DNSProvider] expectedError string }{ { desc: "Success", - mockResponses: MockResponseMap{ - "GET:/v4/domains": &linodego.DomainsPagedResponse{ - PageOptions: &linodego.PageOptions{ - Pages: 1, - Results: 1, - Page: 1, - }, - Data: []linodego.Domain{{ - Domain: "foobar.com", - ID: 1234, - }}, - }, - "GET:/v4/domains/1234/records": &linodego.DomainRecordsPagedResponse{ - PageOptions: &linodego.PageOptions{ - Pages: 1, - Results: 1, - Page: 1, - }, - Data: []linodego.DomainRecord{{ - ID: 1234, - Name: "_acme-challenge", - Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM", - Type: "TXT", - }}, - }, - "DELETE:/v4/domains/1234/records/1234": struct{}{}, - }, + builder: mockBuilder(). + Route("GET /v4/domains", + servermock.JSONEncode(&linodego.DomainsPagedResponse{ + PageOptions: &linodego.PageOptions{ + Pages: 1, + Results: 1, + Page: 1, + }, + Data: []linodego.Domain{{ + Domain: "foobar.com", + ID: 1234, + }}, + })). + Route("GET /v4/domains/1234/records", + servermock.JSONEncode(&linodego.DomainRecordsPagedResponse{ + PageOptions: &linodego.PageOptions{ + Pages: 1, + Results: 1, + Page: 1, + }, + Data: []linodego.DomainRecord{{ + ID: 1234, + Name: "_acme-challenge", + Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM", + Type: "TXT", + }}, + })). + Route("DELETE /v4/domains/1234/records/1234", + servermock.RawStringResponse("{}").WithHeader("Content-Type", "application/json")), }, { desc: "NoDomain", - mockResponses: MockResponseMap{ - "GET:/v4/domains": linodego.APIError{ - Errors: []linodego.APIErrorReason{{ - Reason: "Not found", - }}, - }, - "GET:/v4/domains/1234/records": linodego.APIError{ - Errors: []linodego.APIErrorReason{{ - Reason: "Not found", - }}, - }, - }, + builder: mockBuilder(). + Route("GET /v4/domains", + servermock.JSONEncode(linodego.APIError{ + Errors: []linodego.APIErrorReason{{ + Reason: "Not found", + }}, + }). + WithStatusCode(http.StatusNotFound)). + Route("GET /v4/domains/1234/records", + servermock.JSONEncode(linodego.APIError{ + Errors: []linodego.APIErrorReason{{ + Reason: "Not found", + }}, + }, + ). + WithStatusCode(http.StatusNotFound)), expectedError: "[404] Not found", }, { desc: "DeleteFailed", - mockResponses: MockResponseMap{ - "GET:/v4/domains": linodego.DomainsPagedResponse{ - PageOptions: &linodego.PageOptions{ - Pages: 1, - Results: 1, - Page: 1, - }, - Data: []linodego.Domain{{ - ID: 1234, - Domain: "example.com", - }}, - }, - "GET:/v4/domains/1234/records": linodego.DomainRecordsPagedResponse{ - PageOptions: &linodego.PageOptions{ - Pages: 1, - Results: 1, - Page: 1, - }, - Data: []linodego.DomainRecord{{ - ID: 1234, - Name: "_acme-challenge", - Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM", - Type: "TXT", - }}, - }, - "DELETE:/v4/domains/1234/records/1234": linodego.APIError{ - Errors: []linodego.APIErrorReason{{ - Reason: "Failed to delete domain resource", - }}, - }, - }, + builder: mockBuilder(). + Route("GET /v4/domains", + servermock.JSONEncode(linodego.DomainsPagedResponse{ + PageOptions: &linodego.PageOptions{ + Pages: 1, + Results: 1, + Page: 1, + }, + Data: []linodego.Domain{{ + ID: 1234, + Domain: "example.com", + }}, + })). + Route("GET /v4/domains/1234/records", + servermock.JSONEncode(linodego.DomainRecordsPagedResponse{ + PageOptions: &linodego.PageOptions{ + Pages: 1, + Results: 1, + Page: 1, + }, + Data: []linodego.DomainRecord{{ + ID: 1234, + Name: "_acme-challenge", + Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM", + Type: "TXT", + }}, + })). + Route("DELETE /v4/domains/1234/records/1234", + servermock.JSONEncode(linodego.APIError{ + Errors: []linodego.APIErrorReason{{ + Reason: "Failed to delete domain resource", + }}, + }). + WithStatusCode(http.StatusBadRequest)), expectedError: "[400] Failed to delete domain resource", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - serverURL := setupTest(t, test.mockResponses) + provider := test.builder.Build(t) - p.client.SetBaseURL(serverURL) - - err = p.CleanUp(domain, "", keyAuth) + err := provider.CleanUp(domain, "", keyAuth) if test.expectedError == "" { assert.NoError(t, err) } else { @@ -356,3 +310,16 @@ func TestLiveCleanUp(t *testing.T) { } // TODO implement this test } + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + p, err := NewDNSProvider() + if err != nil { + return nil, err + } + + p.client.SetBaseURL(server.URL) + + return p, nil + }) +} diff --git a/providers/dns/liquidweb/liquidweb.go b/providers/dns/liquidweb/liquidweb.go index c7fd9eeb7..6e93e2a12 100644 --- a/providers/dns/liquidweb/liquidweb.go +++ b/providers/dns/liquidweb/liquidweb.go @@ -10,14 +10,13 @@ import ( "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" lw "github.com/liquidweb/liquidweb-go/client" "github.com/liquidweb/liquidweb-go/network" ) -const defaultBaseURL = "https://api.liquidweb.com" - // Environment variables names. const ( envNamespace = "LIQUID_WEB_" @@ -34,6 +33,10 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const defaultBaseURL = "https://api.liquidweb.com" + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string @@ -52,15 +55,16 @@ func NewDefaultConfig() *Config { BaseURL: defaultBaseURL, TTL: env.GetOneWithFallback(EnvTTL, 300, strconv.Atoi, altEnvName(EnvTTL)), PropagationTimeout: env.GetOneWithFallback(EnvPropagationTimeout, 2*time.Minute, env.ParseSecond, altEnvName(EnvPropagationTimeout)), - PollingInterval: env.GetOneWithFallback(EnvPollingInterval, 2*time.Second, env.ParseSecond, altEnvName(EnvPollingInterval)), + PollingInterval: env.GetOneWithFallback(EnvPollingInterval, dns01.DefaultPollingInterval, env.ParseSecond, altEnvName(EnvPollingInterval)), HTTPTimeout: env.GetOneWithFallback(EnvHTTPTimeout, 1*time.Minute, env.ParseSecond, altEnvName(EnvHTTPTimeout)), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { - config *Config - client *lw.API + config *Config + client *lw.API + recordIDs map[string]int recordIDsMu sync.Mutex } @@ -156,6 +160,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } params := &network.DNSRecordParams{ID: recordID} + _, err := d.client.NetworkDNS.Delete(params) if err != nil { return fmt.Errorf("liquidweb: could not remove TXT record: %w", err) @@ -176,6 +181,7 @@ func (d *DNSProvider) findZone(domain string) (string, error) { // filter the zones on the account to only ones that match var zs []network.DNSZone + for _, item := range zones.Items { if strings.HasSuffix(domain, item.Name) { zs = append(zs, item) diff --git a/providers/dns/liquidweb/liquidweb.toml b/providers/dns/liquidweb/liquidweb.toml index 987b8027d..386b99cab 100644 --- a/providers/dns/liquidweb/liquidweb.toml +++ b/providers/dns/liquidweb/liquidweb.toml @@ -7,7 +7,7 @@ Since = "v3.1.0" Example = ''' LWAPI_USERNAME=someuser \ LWAPI_PASSWORD=somepass \ -lego --email you@example.com --dns liquidweb -d '*.example.com' -d example.com run +lego --dns liquidweb -d '*.example.com' -d example.com run ''' [Configuration] @@ -17,10 +17,10 @@ lego --email you@example.com --dns liquidweb -d '*.example.com' -d example.com r [Configuration.Additional] LWAPI_ZONE = "DNS Zone" LWAPI_URL = "Liquid Web API endpoint" - LWAPI_TTL = "The TTL of the TXT record used for the DNS challenge" - LWAPI_POLLING_INTERVAL = "Time between DNS propagation check" - LWAPI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - LWAPI_HTTP_TIMEOUT = "Maximum waiting time for the DNS records to be created (not verified)" + LWAPI_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + LWAPI_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + LWAPI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + LWAPI_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" [Links] API = "https://api.liquidweb.com/docs/" diff --git a/providers/dns/liquidweb/liquidweb_test.go b/providers/dns/liquidweb/liquidweb_test.go index a26b18e1b..a34d19037 100644 --- a/providers/dns/liquidweb/liquidweb_test.go +++ b/providers/dns/liquidweb/liquidweb_test.go @@ -18,22 +18,6 @@ var envTest = tester.NewEnvTest( EnvZone). WithDomain(envDomain) -func setupTest(t *testing.T, initRecs ...network.DNSRecord) *DNSProvider { - t.Helper() - - serverURL := mockAPIServer(t, initRecs) - - config := NewDefaultConfig() - config.Username = "blars" - config.Password = "tacoman" - config.BaseURL = serverURL - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - return provider -} - func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string @@ -43,16 +27,16 @@ func TestNewDNSProvider(t *testing.T) { { desc: "minimum-success", envVars: map[string]string{ - EnvUsername: "blars", - EnvPassword: "tacoman", + EnvUsername: "user", + EnvPassword: "secret", }, }, { desc: "set-everything", envVars: map[string]string{ - EnvURL: "https://storm.com", - EnvUsername: "blars", - EnvPassword: "tacoman", + EnvURL: "https://storm.example", + EnvUsername: "user", + EnvPassword: "secret", EnvZone: "blars.com", }, }, @@ -64,16 +48,16 @@ func TestNewDNSProvider(t *testing.T) { { desc: "missing username", envVars: map[string]string{ - EnvPassword: "tacoman", - EnvZone: "blars.com", + EnvPassword: "secret", + EnvZone: "blars.example", }, expected: "liquidweb: some credentials information are missing: LIQUID_WEB_USERNAME", }, { desc: "missing password", envVars: map[string]string{ - EnvUsername: "blars", - EnvZone: "blars.com", + EnvUsername: "user", + EnvZone: "blars.example", }, expected: "liquidweb: some credentials information are missing: LIQUID_WEB_PASSWORD", }, @@ -82,6 +66,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -161,15 +146,15 @@ func TestNewDNSProviderConfig(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - provider := setupTest(t) + provider := mockProvider(t) - err := provider.Present("tacoman.com", "", "") + err := provider.Present("tacoman.example", "", "") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { - provider := setupTest(t, network.DNSRecord{ - Name: "_acme-challenge.tacoman.com", + provider := mockProvider(t, network.DNSRecord{ + Name: "_acme-challenge.tacoman.example", RData: "123d==", Type: "TXT", TTL: 300, @@ -179,7 +164,7 @@ func TestDNSProvider_CleanUp(t *testing.T) { provider.recordIDs["123d=="] = 1234567 - err := provider.CleanUp("tacoman.com.", "123d==", "") + err := provider.CleanUp("tacoman.example.", "123d==", "") require.NoError(t, err) } @@ -196,7 +181,7 @@ func TestDNSProvider(t *testing.T) { }{ { desc: "expected successful", - domain: "tacoman.com", + domain: "tacoman.example", token: "123", keyAuth: "456", present: true, @@ -204,7 +189,7 @@ func TestDNSProvider(t *testing.T) { }, { desc: "other successful", - domain: "banana.com", + domain: "banana.example", token: "123", keyAuth: "456", present: true, @@ -212,16 +197,16 @@ func TestDNSProvider(t *testing.T) { }, { desc: "zone not on account", - domain: "huckleberry.com", + domain: "huckleberry.example", token: "123", keyAuth: "456", present: true, - expPresentErr: "no valid zone in account for certificate '_acme-challenge.huckleberry.com'", + expPresentErr: "no valid zone in account for certificate '_acme-challenge.huckleberry.example'", cleanup: false, }, { desc: "ssl for domain", - domain: "sundae.cherry.com", + domain: "sundae.cherry.example", token: "5847953", keyAuth: "34872934", present: true, @@ -229,7 +214,7 @@ func TestDNSProvider(t *testing.T) { }, { desc: "complicated domain", - domain: "always.money.stand.banana.com", + domain: "always.money.stand.banana.example", token: "5847953", keyAuth: "there is always money in the banana stand", present: true, @@ -239,7 +224,7 @@ func TestDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - provider := setupTest(t, test.initRecs...) + provider := mockProvider(t, test.initRecs...) if test.present { err := provider.Present(test.domain, test.token, test.keyAuth) @@ -264,6 +249,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/liquidweb/servermock_test.go b/providers/dns/liquidweb/servermock_test.go index 8c22595af..4886e17f1 100644 --- a/providers/dns/liquidweb/servermock_test.go +++ b/providers/dns/liquidweb/servermock_test.go @@ -1,7 +1,6 @@ package liquidweb import ( - "bytes" "encoding/json" "fmt" "io" @@ -10,11 +9,12 @@ import ( "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/liquidweb/liquidweb-go/network" "github.com/liquidweb/liquidweb-go/types" ) -func mockAPIServer(t *testing.T, initRecs []network.DNSRecord) string { +func mockProvider(t *testing.T, initRecs ...network.DNSRecord) *DNSProvider { t.Helper() recs := make(map[int]network.DNSRecord) @@ -23,157 +23,142 @@ func mockAPIServer(t *testing.T, initRecs []network.DNSRecord) string { recs[int(rec.ID)] = rec } - mux := http.NewServeMux() - mux.Handle("/v1/Network/DNS/Record/delete", mockAPIDelete(recs)) - mux.Handle("/v1/Network/DNS/Record/create", mockAPICreate(recs)) - mux.Handle("/v1/Network/DNS/Zone/list", mockAPIListZones()) - mux.Handle("/bleed/Network/DNS/Record/delete", mockAPIDelete(recs)) - mux.Handle("/bleed/Network/DNS/Record/create", mockAPICreate(recs)) - mux.Handle("/bleed/Network/DNS/Zone/list", mockAPIListZones()) + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Username = "user" + config.Password = "secret" + config.BaseURL = server.URL - server := httptest.NewServer(requireBasicAuth(requireJSON(mux))) - t.Cleanup(server.Close) - - return server.URL -} - -func requireBasicAuth(next http.Handler) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - username, password, ok := r.BasicAuth() - if ok && username == "blars" && password == "tacoman" { - next.ServeHTTP(w, r) - return - } - - http.Error(w, "invalid auth", http.StatusForbidden) - } -} - -func requireJSON(next http.Handler) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - buf := &bytes.Buffer{} - - _, err := buf.ReadFrom(r.Body) - if err != nil { - http.Error(w, "malformed request - json required", http.StatusBadRequest) - return - } - - r.Body = io.NopCloser(buf) - next.ServeHTTP(w, r) - } + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader(). + WithBasicAuth("user", "secret"), + ). + Route("/v1/Network/DNS/Record/delete", mockAPIDelete(recs)). + Route("/v1/Network/DNS/Record/create", mockAPICreate(recs)). + Route("/v1/Network/DNS/Zone/list", mockAPIListZones()). + Route("/bleed/Network/DNS/Record/delete", mockAPIDelete(recs)). + Route("/bleed/Network/DNS/Record/create", mockAPICreate(recs)). + Route("/bleed/Network/DNS/Zone/list", mockAPIListZones()). + Build(t) } func mockAPICreate(recs map[int]network.DNSRecord) http.HandlerFunc { _, mockAPIServerZones := makeMockZones() - return func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) + return func(rw http.ResponseWriter, req *http.Request) { + body, err := io.ReadAll(req.Body) if err != nil { - http.Error(w, "invalid request", http.StatusInternalServerError) + http.Error(rw, "invalid request", http.StatusInternalServerError) return } - req := struct { + payload := struct { Params network.DNSRecord `json:"params"` }{} - if err = json.Unmarshal(body, &req); err != nil { - http.Error(w, makeEncodingError(body), http.StatusBadRequest) + if err = json.Unmarshal(body, &payload); err != nil { + http.Error(rw, makeEncodingError(body), http.StatusBadRequest) return } - req.Params.ID = types.FlexInt(rand.Intn(10000000)) - req.Params.ZoneID = types.FlexInt(mockAPIServerZones[req.Params.Name]) - if _, exists := recs[int(req.Params.ID)]; exists { - http.Error(w, "dns record already exists", http.StatusTeapot) + payload.Params.ID = types.FlexInt(rand.Intn(10000000)) + payload.Params.ZoneID = types.FlexInt(mockAPIServerZones[payload.Params.Name]) + + if _, exists := recs[int(payload.Params.ID)]; exists { + http.Error(rw, "dns record already exists", http.StatusTeapot) return } - recs[int(req.Params.ID)] = req.Params - resp, err := json.Marshal(req.Params) + recs[int(payload.Params.ID)] = payload.Params + + resp, err := json.Marshal(payload.Params) if err != nil { - http.Error(w, "", http.StatusInternalServerError) + http.Error(rw, "", http.StatusInternalServerError) return } - http.Error(w, string(resp), http.StatusOK) + + http.Error(rw, string(resp), http.StatusOK) } } func mockAPIDelete(recs map[int]network.DNSRecord) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) + return func(rw http.ResponseWriter, req *http.Request) { + body, err := io.ReadAll(req.Body) if err != nil { - http.Error(w, "invalid request", http.StatusInternalServerError) + http.Error(rw, "invalid request", http.StatusInternalServerError) return } - req := struct { + payload := struct { Params struct { Name string `json:"name"` ID int `json:"id"` } `json:"params"` }{} - if err := json.Unmarshal(body, &req); err != nil { - http.Error(w, makeEncodingError(body), http.StatusBadRequest) + if err := json.Unmarshal(body, &payload); err != nil { + http.Error(rw, makeEncodingError(body), http.StatusBadRequest) return } - if req.Params.ID == 0 { - http.Error(w, `{"error":"","error_class":"LW::Exception::Input::Multiple","errors":[{"error":"","error_class":"LW::Exception::Input::Required","field":"id","full_message":"The required field 'id' was missing a value.","position":null}],"field":["id"],"full_message":"The following input errors occurred:\nThe required field 'id' was missing a value.","type":null}`, http.StatusOK) + if payload.Params.ID == 0 { + http.Error(rw, `{"error":"","error_class":"LW::Exception::Input::Multiple","errors":[{"error":"","error_class":"LW::Exception::Input::Required","field":"id","full_message":"The required field 'id' was missing a value.","position":null}],"field":["id"],"full_message":"The following input errors occurred:\nThe required field 'id' was missing a value.","type":null}`, http.StatusOK) return } - if _, ok := recs[req.Params.ID]; !ok { - http.Error(w, fmt.Sprintf(`{"error":"","error_class":"LW::Exception::RecordNotFound","field":"network_dns_rr","full_message":"Record 'network_dns_rr: %d' not found","input":"%d","public_message":null}`, req.Params.ID, req.Params.ID), http.StatusOK) + if _, ok := recs[payload.Params.ID]; !ok { + http.Error(rw, fmt.Sprintf(`{"error":"","error_class":"LW::Exception::RecordNotFound","field":"network_dns_rr","full_message":"Record 'network_dns_rr: %d' not found","input":"%d","public_message":null}`, payload.Params.ID, payload.Params.ID), http.StatusOK) return } - delete(recs, req.Params.ID) - http.Error(w, fmt.Sprintf("{\"deleted\":%d}", req.Params.ID), http.StatusOK) + + delete(recs, payload.Params.ID) + http.Error(rw, fmt.Sprintf("{\"deleted\":%d}", payload.Params.ID), http.StatusOK) } } func mockAPIListZones() http.HandlerFunc { mockZones, mockAPIServerZones := makeMockZones() - return func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) + return func(rw http.ResponseWriter, req *http.Request) { + body, err := io.ReadAll(req.Body) if err != nil { - http.Error(w, "invalid request", http.StatusInternalServerError) + http.Error(rw, "invalid request", http.StatusInternalServerError) return } - req := struct { + payload := struct { Params struct { PageNum int `json:"page_num"` } `json:"params"` }{} - if err = json.Unmarshal(body, &req); err != nil { - http.Error(w, makeEncodingError(body), http.StatusBadRequest) + if err = json.Unmarshal(body, &payload); err != nil { + http.Error(rw, makeEncodingError(body), http.StatusBadRequest) return } switch { - case req.Params.PageNum < 1: - req.Params.PageNum = 1 - case req.Params.PageNum > len(mockZones): - req.Params.PageNum = len(mockZones) + case payload.Params.PageNum < 1: + payload.Params.PageNum = 1 + case payload.Params.PageNum > len(mockZones): + payload.Params.PageNum = len(mockZones) } - resp := mockZones[req.Params.PageNum] + + resp := mockZones[payload.Params.PageNum] resp.ItemTotal = types.FlexInt(len(mockAPIServerZones)) - resp.PageNum = types.FlexInt(req.Params.PageNum) + resp.PageNum = types.FlexInt(payload.Params.PageNum) resp.PageSize = 5 resp.PageTotal = types.FlexInt(len(mockZones)) var respBody []byte if respBody, err = json.Marshal(resp); err == nil { - http.Error(w, string(respBody), http.StatusOK) + http.Error(rw, string(respBody), http.StatusOK) return } - http.Error(w, "", http.StatusInternalServerError) + http.Error(rw, "", http.StatusInternalServerError) } } @@ -187,38 +172,38 @@ func makeMockZones() (map[int]network.DNSZoneList, map[string]int) { Items: []network.DNSZone{ { ID: 1, - Name: "blars.com", + Name: "blars.example", Active: 1, DelegationStatus: "CORRECT", - PrimaryNameserver: "ns.liquidweb.com", + PrimaryNameserver: "ns.example.org", }, { ID: 2, - Name: "tacoman.com", + Name: "tacoman.example", Active: 1, DelegationStatus: "CORRECT", - PrimaryNameserver: "ns.liquidweb.com", + PrimaryNameserver: "ns.example.org", }, { ID: 3, - Name: "storm.com", + Name: "storm.example", Active: 1, DelegationStatus: "CORRECT", - PrimaryNameserver: "ns.liquidweb.com", + PrimaryNameserver: "ns.example.org", }, { ID: 4, - Name: "not-apple.com", + Name: "not-apple.example", Active: 1, DelegationStatus: "BAD_NAMESERVERS", - PrimaryNameserver: "ns.liquidweb.com", + PrimaryNameserver: "ns.example.org", }, { ID: 5, Name: "example.com", Active: 1, DelegationStatus: "BAD_NAMESERVERS", - PrimaryNameserver: "ns.liquidweb.com", + PrimaryNameserver: "ns.example.org", }, }, }, @@ -226,38 +211,38 @@ func makeMockZones() (map[int]network.DNSZoneList, map[string]int) { Items: []network.DNSZone{ { ID: 6, - Name: "banana.com", + Name: "banana.example", Active: 1, DelegationStatus: "NXDOMAIN", - PrimaryNameserver: "ns.liquidweb.com", + PrimaryNameserver: "ns.example.org", }, { ID: 7, - Name: "cherry.com", + Name: "cherry.example", Active: 1, DelegationStatus: "SERVFAIL", - PrimaryNameserver: "ns.liquidweb.com", + PrimaryNameserver: "ns.example.org", }, { ID: 8, - Name: "dates.com", + Name: "dates.example", Active: 1, DelegationStatus: "SERVFAIL", - PrimaryNameserver: "ns.liquidweb.com", + PrimaryNameserver: "ns.example.org", }, { ID: 9, - Name: "eggplant.com", + Name: "eggplant.example", Active: 1, DelegationStatus: "SERVFAIL", - PrimaryNameserver: "ns.liquidweb.com", + PrimaryNameserver: "ns.example.org", }, { ID: 10, - Name: "fig.com", + Name: "fig.example", Active: 1, DelegationStatus: "UNKNOWN", - PrimaryNameserver: "ns.liquidweb.com", + PrimaryNameserver: "ns.example.org", }, }, }, @@ -265,41 +250,43 @@ func makeMockZones() (map[int]network.DNSZoneList, map[string]int) { Items: []network.DNSZone{ { ID: 11, - Name: "grapes.com", + Name: "grapes.example", Active: 1, DelegationStatus: "UNKNOWN", - PrimaryNameserver: "ns.liquidweb.com", + PrimaryNameserver: "ns.example.org", }, { ID: 12, - Name: "money.banana.com", + Name: "money.banana.example", Active: 1, DelegationStatus: "UNKNOWN", - PrimaryNameserver: "ns.liquidweb.com", + PrimaryNameserver: "ns.example.org", }, { ID: 13, - Name: "money.stand.banana.com", + Name: "money.stand.banana.example", Active: 1, DelegationStatus: "UNKNOWN", - PrimaryNameserver: "ns.liquidweb.com", + PrimaryNameserver: "ns.example.org", }, { ID: 14, - Name: "stand.banana.com", + Name: "stand.banana.example", Active: 1, DelegationStatus: "UNKNOWN", - PrimaryNameserver: "ns.liquidweb.com", + PrimaryNameserver: "ns.example.org", }, }, }, } mockAPIServerZones := make(map[string]int) + for _, page := range mockZones { for _, zone := range page.Items { mockAPIServerZones[zone.Name] = int(zone.ID) } } + return mockZones, mockAPIServerZones } diff --git a/providers/dns/loopia/internal/client.go b/providers/dns/loopia/internal/client.go index d521ffeec..0e9513024 100644 --- a/providers/dns/loopia/internal/client.go +++ b/providers/dns/loopia/internal/client.go @@ -37,7 +37,7 @@ func NewClient(apiUser, apiPassword string) *Client { } // AddTXTRecord adds a TXT record. -func (c *Client) AddTXTRecord(ctx context.Context, domain string, subdomain string, ttl int, value string) error { +func (c *Client) AddTXTRecord(ctx context.Context, domain, subdomain string, ttl int, value string) error { call := &methodCall{ MethodName: "addZoneRecord", Params: []param{ @@ -67,7 +67,7 @@ func (c *Client) AddTXTRecord(ctx context.Context, domain string, subdomain stri } // RemoveTXTRecord removes a TXT record. -func (c *Client) RemoveTXTRecord(ctx context.Context, domain string, subdomain string, recordID int) error { +func (c *Client) RemoveTXTRecord(ctx context.Context, domain, subdomain string, recordID int) error { call := &methodCall{ MethodName: "removeZoneRecord", Params: []param{ @@ -89,7 +89,7 @@ func (c *Client) RemoveTXTRecord(ctx context.Context, domain string, subdomain s } // GetTXTRecords gets TXT records. -func (c *Client) GetTXTRecords(ctx context.Context, domain string, subdomain string) ([]RecordObj, error) { +func (c *Client) GetTXTRecords(ctx context.Context, domain, subdomain string) ([]RecordObj, error) { call := &methodCall{ MethodName: "getZoneRecords", Params: []param{ diff --git a/providers/dns/loopia/internal/client_test.go b/providers/dns/loopia/internal/client_test.go index 4fe2e1fd0..fed7d94f1 100644 --- a/providers/dns/loopia/internal/client_test.go +++ b/providers/dns/loopia/internal/client_test.go @@ -1,65 +1,80 @@ package internal import ( - "context" "encoding/xml" - "fmt" - "io" "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func mockBuilder(password string) *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("apiuser", password) + client.HTTPClient = server.Client() + client.BaseURL = server.URL + "/" + + return client, nil + }, + servermock.CheckHeader().WithContentType("text/xml"), + ) +} + func TestClient_AddZoneRecord(t *testing.T) { - serverResponses := map[string]string{ - addZoneRecordGoodAuth: responseOk, - addZoneRecordBadAuth: responseAuthError, - addZoneRecordNonValidDomain: responseUnknownError, - addZoneRecordEmptyResponse: "", - } - - serverURL := createFakeServer(t, serverResponses) - testCases := []struct { desc string password string domain string + request string + response string err string }{ { desc: "auth ok", password: "goodpassword", domain: exampleDomain, + request: addZoneRecordGoodAuth, + response: responseOk, }, { desc: "auth error", password: "badpassword", domain: exampleDomain, + request: addZoneRecordBadAuth, + response: responseAuthError, err: "authentication error", }, { desc: "unknown error", password: "goodpassword", domain: "badexample.com", + request: addZoneRecordNonValidDomain, + response: responseUnknownError, err: `unknown error: "UNKNOWN_ERROR"`, }, { desc: "empty response", password: "goodpassword", domain: "empty.com", + request: addZoneRecordEmptyResponse, + response: "", err: "unmarshal error: EOF", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := NewClient("apiuser", test.password) - client.BaseURL = serverURL + "/" + client := mockBuilder(test.password). + Route("POST /", + servermock.RawStringResponse(test.response), + servermock.CheckRequestBody(test.request)). + Build(t) - err := client.AddTXTRecord(context.Background(), test.domain, exampleSubDomain, 123, "TXTrecord") + err := client.AddTXTRecord(t.Context(), test.domain, exampleSubDomain, 123, "TXTrecord") if test.err == "" { require.NoError(t, err) } else { @@ -71,52 +86,56 @@ func TestClient_AddZoneRecord(t *testing.T) { } func TestClient_RemoveSubdomain(t *testing.T) { - serverResponses := map[string]string{ - removeSubdomainGoodAuth: responseOk, - removeSubdomainBadAuth: responseAuthError, - removeSubdomainNonValidDomain: responseUnknownError, - removeSubdomainEmptyResponse: "", - } - - serverURL := createFakeServer(t, serverResponses) - testCases := []struct { desc string password string domain string + request string + response string err string }{ { desc: "auth ok", password: "goodpassword", domain: exampleDomain, + request: removeSubdomainGoodAuth, + response: responseOk, }, { desc: "auth error", password: "badpassword", domain: exampleDomain, + request: removeSubdomainBadAuth, + response: responseAuthError, err: "authentication error", }, { desc: "unknown error", password: "goodpassword", domain: "badexample.com", + request: removeSubdomainNonValidDomain, + response: responseUnknownError, err: `unknown error: "UNKNOWN_ERROR"`, }, { desc: "empty response", password: "goodpassword", domain: "empty.com", + request: removeSubdomainEmptyResponse, + response: "", err: "unmarshal error: EOF", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := NewClient("apiuser", test.password) - client.BaseURL = serverURL + "/" + client := mockBuilder(test.password). + Route("POST /", + servermock.RawStringResponse(test.response), + servermock.CheckRequestBody(test.request)). + Build(t) - err := client.RemoveSubdomain(context.Background(), test.domain, exampleSubDomain) + err := client.RemoveSubdomain(t.Context(), test.domain, exampleSubDomain) if test.err == "" { require.NoError(t, err) } else { @@ -128,52 +147,56 @@ func TestClient_RemoveSubdomain(t *testing.T) { } func TestClient_RemoveZoneRecord(t *testing.T) { - serverResponses := map[string]string{ - removeRecordGoodAuth: responseOk, - removeRecordBadAuth: responseAuthError, - removeRecordNonValidDomain: responseUnknownError, - removeRecordEmptyResponse: "", - } - - serverURL := createFakeServer(t, serverResponses) - testCases := []struct { desc string password string domain string + request string + response string err string }{ { desc: "auth ok", password: "goodpassword", domain: exampleDomain, + request: removeRecordGoodAuth, + response: responseOk, }, { desc: "auth error", password: "badpassword", domain: exampleDomain, + request: removeRecordBadAuth, + response: responseAuthError, err: "authentication error", }, { desc: "uknown error", password: "goodpassword", domain: "badexample.com", + request: removeRecordNonValidDomain, + response: responseUnknownError, err: `unknown error: "UNKNOWN_ERROR"`, }, { desc: "empty response", password: "goodpassword", domain: "empty.com", + request: removeRecordEmptyResponse, + response: "", err: "unmarshal error: EOF", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := NewClient("apiuser", test.password) - client.BaseURL = serverURL + "/" + client := mockBuilder(test.password). + Route("POST /", + servermock.RawStringResponse(test.response), + servermock.CheckRequestBody(test.request)). + Build(t) - err := client.RemoveTXTRecord(context.Background(), test.domain, exampleSubDomain, 12345678) + err := client.RemoveTXTRecord(t.Context(), test.domain, exampleSubDomain, 12345678) if test.err == "" { require.NoError(t, err) } else { @@ -185,16 +208,13 @@ func TestClient_RemoveZoneRecord(t *testing.T) { } func TestClient_GetZoneRecord(t *testing.T) { - serverResponses := map[string]string{ - getZoneRecords: getZoneRecordsResponse, - } + client := mockBuilder("goodpassword"). + Route("POST /", + servermock.RawStringResponse(getZoneRecordsResponse), + servermock.CheckRequestBody(getZoneRecords)). + Build(t) - serverURL := createFakeServer(t, serverResponses) - - client := NewClient("apiuser", "goodpassword") - client.BaseURL = serverURL + "/" - - recordObjs, err := client.GetTXTRecords(context.Background(), exampleDomain, exampleSubDomain) + recordObjs, err := client.GetTXTRecords(t.Context(), exampleDomain, exampleSubDomain) require.NoError(t, err) expected := []RecordObj{ @@ -206,27 +226,15 @@ func TestClient_GetZoneRecord(t *testing.T) { RecordID: 12345678, }, } - assert.EqualValues(t, expected, recordObjs) + assert.Equal(t, expected, recordObjs) } func TestClient_rpcCall_404(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - w.WriteHeader(http.StatusNotFound) - - _, err = fmt.Fprint(w, "") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - })) - - t.Cleanup(server.Close) + client := mockBuilder("apipassword"). + Route("POST /", + servermock.RawStringResponse(""). + WithStatusCode(http.StatusNotFound)). + Build(t) call := &methodCall{ MethodName: "dummyMethod", @@ -235,29 +243,15 @@ func TestClient_rpcCall_404(t *testing.T) { }, } - client := NewClient("apiuser", "apipassword") - client.BaseURL = server.URL + "/" - - err := client.rpcCall(context.Background(), call, &responseString{}) + err := client.rpcCall(t.Context(), call, &responseString{}) require.EqualError(t, err, "unexpected status code: [status code: 404] body: ") } func TestClient_rpcCall_RPCError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - _, err = fmt.Fprint(w, responseRPCError) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - })) - - t.Cleanup(server.Close) + client := mockBuilder("apipassword"). + Route("POST /", + servermock.RawStringResponse(responseRPCError)). + Build(t) call := &methodCall{ MethodName: "getDomains", @@ -266,10 +260,7 @@ func TestClient_rpcCall_RPCError(t *testing.T) { }, } - client := NewClient("apiuser", "apipassword") - client.BaseURL = server.URL + "/" - - err := client.rpcCall(context.Background(), call, &responseString{}) + err := client.rpcCall(t.Context(), call, &responseString{}) require.EqualError(t, err, "RPC Error: (201) Method signature error: 42") } @@ -301,37 +292,3 @@ func TestUnmarshallFaultyRecordObject(t *testing.T) { }) } } - -func createFakeServer(t *testing.T, serverResponses map[string]string) string { - t.Helper() - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Content-Type") != "text/xml" { - http.Error(w, fmt.Sprintf("invalid content type: %s", r.Header.Get("Content-Type")), http.StatusBadRequest) - return - } - - req, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - resp, ok := serverResponses[string(req)] - if !ok { - http.Error(w, "no response for request", http.StatusBadRequest) - return - } - - _, err = fmt.Fprint(w, resp) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - return server.URL -} diff --git a/providers/dns/loopia/internal/types.go b/providers/dns/loopia/internal/types.go index c286c01fd..c3425c8b1 100644 --- a/providers/dns/loopia/internal/types.go +++ b/providers/dns/loopia/internal/types.go @@ -66,6 +66,7 @@ type response interface { type responseString struct { responseFault + Value string `xml:"params>param>value>string"` } @@ -88,6 +89,7 @@ func (e RPCError) Error() string { type recordObjectsResponse struct { responseFault + XMLName xml.Name `xml:"methodResponse"` Params []RecordObj `xml:"params>param>value>array>data>value>struct"` } @@ -102,6 +104,7 @@ type RecordObj struct { func (r *RecordObj) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { var name string + for { t, err := d.Token() if err != nil { @@ -144,6 +147,7 @@ func (r *RecordObj) decodeValueString(name string, d *xml.Decoder, start xml.Sta } s = strings.TrimSpace(s) + switch name { case "type": r.Type = s diff --git a/providers/dns/loopia/loopia.go b/providers/dns/loopia/loopia.go index 582a247fa..be3416ddf 100644 --- a/providers/dns/loopia/loopia.go +++ b/providers/dns/loopia/loopia.go @@ -9,13 +9,13 @@ import ( "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/loopia/internal" ) -const minTTL = 300 - // Environment variables names. const ( envNamespace = "LOOPIA_" @@ -30,10 +30,14 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const minTTL = 300 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + type dnsClient interface { - AddTXTRecord(ctx context.Context, domain string, subdomain string, ttl int, value string) error - RemoveTXTRecord(ctx context.Context, domain string, subdomain string, recordID int) error - GetTXTRecords(ctx context.Context, domain string, subdomain string) ([]internal.RecordObj, error) + AddTXTRecord(ctx context.Context, domain, subdomain string, ttl int, value string) error + RemoveTXTRecord(ctx context.Context, domain, subdomain string, recordID int) error + GetTXTRecords(ctx context.Context, domain, subdomain string) ([]internal.RecordObj, error) RemoveSubdomain(ctx context.Context, domain, subdomain string) error } @@ -53,9 +57,9 @@ func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 40*time.Minute), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 60*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ - Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 60*time.Second), + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, time.Minute), }, } } @@ -110,6 +114,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + if config.BaseURL != "" { client.BaseURL = config.BaseURL } diff --git a/providers/dns/loopia/loopia.toml b/providers/dns/loopia/loopia.toml index f1065b35e..a201852c9 100644 --- a/providers/dns/loopia/loopia.toml +++ b/providers/dns/loopia/loopia.toml @@ -7,7 +7,7 @@ Since = "v4.2.0" Example = ''' LOOPIA_API_USER=xxxxxxxx \ LOOPIA_API_PASSWORD=yyyyyyyy \ -lego --email you@example.com --dns loopia -d '*.example.com' -d example.com run +lego --dns loopia -d '*.example.com' -d example.com run ''' Additional = ''' @@ -29,10 +29,10 @@ It needs to have the following permissions: LOOPIA_API_PASSWORD = "API password" [Configuration.Additional] LOOPIA_API_URL = "API endpoint. Ex: https://api.loopia.se/RPCSERV or https://api.loopia.rs/RPCSERV" - LOOPIA_POLLING_INTERVAL = "Time between DNS propagation check" - LOOPIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - LOOPIA_TTL = "The TTL of the TXT record used for the DNS challenge" - LOOPIA_HTTP_TIMEOUT = "API request timeout" + LOOPIA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2400)" + LOOPIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + LOOPIA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + LOOPIA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" [Links] API = "https://www.loopia.com/api" diff --git a/providers/dns/loopia/loopia_mock_test.go b/providers/dns/loopia/loopia_mock_test.go index 93f26af06..fb0bcaa2b 100644 --- a/providers/dns/loopia/loopia_mock_test.go +++ b/providers/dns/loopia/loopia_mock_test.go @@ -215,17 +215,17 @@ type mockedClient struct { mock.Mock } -func (c *mockedClient) RemoveTXTRecord(ctx context.Context, domain string, subdomain string, recordID int) error { +func (c *mockedClient) RemoveTXTRecord(ctx context.Context, domain, subdomain string, recordID int) error { args := c.Called(domain, subdomain, recordID) return args.Error(0) } -func (c *mockedClient) AddTXTRecord(ctx context.Context, domain string, subdomain string, ttl int, value string) error { +func (c *mockedClient) AddTXTRecord(ctx context.Context, domain, subdomain string, ttl int, value string) error { args := c.Called(domain, subdomain, ttl, value) return args.Error(0) } -func (c *mockedClient) GetTXTRecords(ctx context.Context, domain string, subdomain string) ([]internal.RecordObj, error) { +func (c *mockedClient) GetTXTRecords(ctx context.Context, domain, subdomain string) ([]internal.RecordObj, error) { args := c.Called(domain, subdomain) return args.Get(0).([]internal.RecordObj), args.Error(1) } diff --git a/providers/dns/loopia/loopia_test.go b/providers/dns/loopia/loopia_test.go index e397c9639..b3163fc77 100644 --- a/providers/dns/loopia/loopia_test.go +++ b/providers/dns/loopia/loopia_test.go @@ -103,6 +103,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -192,6 +193,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -205,6 +207,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/luadns/internal/client.go b/providers/dns/luadns/internal/client.go index 8e46418f2..5ce9cca86 100644 --- a/providers/dns/luadns/internal/client.go +++ b/providers/dns/luadns/internal/client.go @@ -49,6 +49,7 @@ func (c *Client) ListZones(ctx context.Context) ([]DNSZone, error) { } var zones []DNSZone + err = c.do(req, &zones) if err != nil { return nil, fmt.Errorf("could not list zones: %w", err) @@ -68,6 +69,7 @@ func (c *Client) CreateRecord(ctx context.Context, zone DNSZone, newRecord DNSRe } var record *DNSRecord + err = c.do(req, &record) if err != nil { return nil, fmt.Errorf("could not create record %#v: %w", record, err) @@ -153,6 +155,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errResp errorResponse + err := json.Unmarshal(raw, &errResp) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/luadns/internal/client_test.go b/providers/dns/luadns/internal/client_test.go index 1fd3efd74..0a3a79e6c 100644 --- a/providers/dns/luadns/internal/client_test.go +++ b/providers/dns/luadns/internal/client_test.go @@ -1,63 +1,34 @@ package internal import ( - "context" - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, apiToken string) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder(apiToken string) *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("me", apiToken) + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("me", apiToken) - client.baseURL, _ = url.Parse(server.URL) - client.HTTPClient = server.Client() - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("me", apiToken)) } func TestClient_ListZones(t *testing.T) { - client, mux := setupTest(t, "secretA") + client := mockBuilder("secretA"). + Route("GET /v1/zones", servermock.ResponseFromFixture("list_zones.json")). + Build(t) - mux.HandleFunc("/v1/zones", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("invalid method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Basic bWU6c2VjcmV0QQ==" { - http.Error(rw, fmt.Sprintf("invalid authentication: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open("./fixtures/list_zones.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - zones, err := client.ListZones(context.Background()) + zones, err := client.ListZones(t.Context()) require.NoError(t, err) expected := []DNSZone{ @@ -89,33 +60,11 @@ func TestClient_ListZones(t *testing.T) { } func TestClient_CreateRecord(t *testing.T) { - client, mux := setupTest(t, "secretB") - - mux.HandleFunc("/v1/zones/1/records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("invalid method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Basic bWU6c2VjcmV0Qg==" { - http.Error(rw, fmt.Sprintf("invalid authentication: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open("./fixtures/create_record.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder("secretB"). + Route("POST /v1/zones/1/records", + servermock.ResponseFromFixture("create_record.json"), + servermock.CheckRequestJSONBody(`{"name":"example.com.","type":"MX","content":"10 mail.example.com.","ttl":300}`)). + Build(t) zone := DNSZone{ID: 1} @@ -126,7 +75,7 @@ func TestClient_CreateRecord(t *testing.T) { TTL: 300, } - newRecord, err := client.CreateRecord(context.Background(), zone, record) + newRecord, err := client.CreateRecord(t.Context(), zone, record) require.NoError(t, err) expected := &DNSRecord{ @@ -142,33 +91,11 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t, "secretC") - - mux.HandleFunc("/v1/zones/1/records/2", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, fmt.Sprintf("invalid method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Basic bWU6c2VjcmV0Qw==" { - http.Error(rw, fmt.Sprintf("invalid authentication: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open("./fixtures/delete_record.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder("secretC"). + Route("DELETE /v1/zones/1/records/2", + servermock.ResponseFromFixture("delete_record.json"), + servermock.CheckRequestJSONBody(`{"id":2,"name":"example.com.","type":"MX","content":"10 mail.example.com.","ttl":300,"zone_id":1}`)). + Build(t) record := &DNSRecord{ ID: 2, @@ -179,6 +106,6 @@ func TestClient_DeleteRecord(t *testing.T) { ZoneID: 1, } - err := client.DeleteRecord(context.Background(), record) + err := client.DeleteRecord(t.Context(), record) require.NoError(t, err) } diff --git a/providers/dns/luadns/luadns.go b/providers/dns/luadns/luadns.go index 97261e157..68b9c66b8 100644 --- a/providers/dns/luadns/luadns.go +++ b/providers/dns/luadns/luadns.go @@ -10,13 +10,13 @@ import ( "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/luadns/internal" ) -const minTTL = 300 - // Environment variables names. const ( envNamespace = "LUADNS_" @@ -30,6 +30,10 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const minTTL = 300 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIUsername string @@ -45,7 +49,7 @@ func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, @@ -97,11 +101,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ - config: config, - client: client, - recordsMu: sync.Mutex{}, - records: make(map[string]*internal.DNSRecord), + config: config, + client: client, + records: make(map[string]*internal.DNSRecord), }, nil } diff --git a/providers/dns/luadns/luadns.toml b/providers/dns/luadns/luadns.toml index b55751f55..e56fac0b6 100644 --- a/providers/dns/luadns/luadns.toml +++ b/providers/dns/luadns/luadns.toml @@ -7,7 +7,7 @@ Since = "v3.7.0" Example = ''' LUADNS_API_USERNAME=youremail \ LUADNS_API_TOKEN=xxxxxxxx \ -lego --email you@example.com --dns luadns -d '*.example.com' -d example.com run +lego --dns luadns -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,10 +15,10 @@ lego --email you@example.com --dns luadns -d '*.example.com' -d example.com run LUADNS_API_USERNAME = "Username (your email)" LUADNS_API_TOKEN = "API token" [Configuration.Additional] - LUADNS_POLLING_INTERVAL = "Time between DNS propagation check" - LUADNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - LUADNS_TTL = "The TTL of the TXT record used for the DNS challenge" - LUADNS_HTTP_TIMEOUT = "API request timeout" + LUADNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + LUADNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + LUADNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + LUADNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://luadns.com/api.html" diff --git a/providers/dns/luadns/luadns_test.go b/providers/dns/luadns/luadns_test.go index ea4d06ae1..a1aa36872 100644 --- a/providers/dns/luadns/luadns_test.go +++ b/providers/dns/luadns/luadns_test.go @@ -58,6 +58,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -199,6 +200,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -212,6 +214,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/mailinabox/mailinabox.go b/providers/dns/mailinabox/mailinabox.go index 4d366379e..cf6202a92 100644 --- a/providers/dns/mailinabox/mailinabox.go +++ b/providers/dns/mailinabox/mailinabox.go @@ -5,10 +5,13 @@ import ( "context" "errors" "fmt" + "net/http" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/nrdcg/mailinabox" ) @@ -22,8 +25,11 @@ const ( 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 { Email string @@ -31,6 +37,7 @@ type Config struct { BaseURL string PropagationTimeout time.Duration PollingInterval time.Duration + HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. @@ -38,6 +45,9 @@ func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, } } @@ -78,7 +88,13 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("mailinabox: missing base URL") } - client, err := mailinabox.New(config.BaseURL, config.Email, config.Password) + if config.HTTPClient == nil { + config.HTTPClient = &http.Client{Timeout: 30 * time.Second} + } + + config.HTTPClient = clientdebug.Wrap(config.HTTPClient) + + client, err := mailinabox.New(config.BaseURL, config.Email, config.Password, mailinabox.WithHTTPClient(config.HTTPClient)) if err != nil { return nil, fmt.Errorf("mailinabox: %w", err) } diff --git a/providers/dns/mailinabox/mailinabox.toml b/providers/dns/mailinabox/mailinabox.toml index 8ee282396..74d8aabbc 100644 --- a/providers/dns/mailinabox/mailinabox.toml +++ b/providers/dns/mailinabox/mailinabox.toml @@ -8,7 +8,7 @@ Example = ''' MAILINABOX_EMAIL=user@example.com \ MAILINABOX_PASSWORD=yyyy \ MAILINABOX_BASE_URL=https://box.example.com \ -lego --email you@example.com --dns mailinabox -d '*.example.com' -d example.com run +lego --dns mailinabox -d '*.example.com' -d example.com run ''' [Configuration] @@ -17,8 +17,9 @@ lego --email you@example.com --dns mailinabox -d '*.example.com' -d example.com MAILINABOX_PASSWORD = "User password" MAILINABOX_BASE_URL = "Base API URL (ex: https://box.example.com)" [Configuration.Additional] - MAILINABOX_POLLING_INTERVAL = "Time between DNS propagation check" - MAILINABOX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + MAILINABOX_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)" + MAILINABOX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + MAILINABOX_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://mailinabox.email/api-docs.html" diff --git a/providers/dns/mailinabox/mailinabox_test.go b/providers/dns/mailinabox/mailinabox_test.go index 1b95c220d..11143a11f 100644 --- a/providers/dns/mailinabox/mailinabox_test.go +++ b/providers/dns/mailinabox/mailinabox_test.go @@ -59,6 +59,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -136,6 +137,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -149,6 +151,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/manageengine/internal/client.go b/providers/dns/manageengine/internal/client.go new file mode 100644 index 000000000..b5a7dbae7 --- /dev/null +++ b/providers/dns/manageengine/internal/client.go @@ -0,0 +1,199 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const defaultBaseURL = "https://clouddns.manageengine.com/v1" + +// Client the ManageEngine CloudDNS API client. +type Client struct { + baseURL *url.URL + httpClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(hc *http.Client) *Client { + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + baseURL: baseURL, + httpClient: hc, + } +} + +// GetAllZones gets all zones. +// https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#GET_All +func (c *Client) GetAllZones(ctx context.Context) ([]Zone, error) { + endpoint := c.baseURL.JoinPath("dns", "domain") + + req, err := newRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var results []Zone + + err = c.do(req, &results) + if err != nil { + return nil, err + } + + return results, nil +} + +// GetAllZoneRecords gets all "zone records" for a zone. +// https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#GET_All_9 +func (c *Client) GetAllZoneRecords(ctx context.Context, zoneID int) ([]ZoneRecord, error) { + endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(zoneID), "records", "SPF_TXT") + + req, err := newRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var results []ZoneRecord + + err = c.do(req, &results) + if err != nil { + return nil, err + } + + return results, nil +} + +// DeleteZoneRecord deletes a "zone record". +// https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#DEL_Delete_10 +func (c *Client) DeleteZoneRecord(ctx context.Context, zoneID, domainID int) error { + endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(zoneID), "records", "SPF_TXT", strconv.Itoa(domainID)) + + req, err := newRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + var results APIResponse + + return c.do(req, &results) +} + +// CreateZoneRecord creates a "zone record". +// https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#POST_Create_10 +func (c *Client) CreateZoneRecord(ctx context.Context, zoneID int, record ZoneRecord) error { + endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(zoneID), "records", "SPF_TXT", "/") + + req, err := newRequest(ctx, http.MethodPost, endpoint, []ZoneRecord{record}) + if err != nil { + return err + } + + var results APIResponse + + return c.do(req, &results) +} + +// UpdateZoneRecord update an existing "zone record". +// https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#PUT_Update_10 +func (c *Client) UpdateZoneRecord(ctx context.Context, record ZoneRecord) error { + if record.SpfTxtDomainID == 0 { + return errors.New("SpfTxtDomainID is empty") + } + + if record.ZoneID == 0 { + return errors.New("ZoneID is empty") + } + + endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(record.ZoneID), "records", "SPF_TXT", strconv.Itoa(record.SpfTxtDomainID), "/") + + req, err := newRequest(ctx, http.MethodPut, endpoint, []ZoneRecord{record}) + if err != nil { + return err + } + + var results APIResponse + + return c.do(req, &results) +} + +func (c *Client) do(req *http.Request, result any) error { + resp, err := c.httpClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + var body io.Reader = http.NoBody + + if payload != nil { + buf := new(bytes.Buffer) + + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + + values := url.Values{} + values.Set("config", buf.String()) + body = strings.NewReader(values.Encode()) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return fmt.Errorf("[status code: %d] %w", resp.StatusCode, &errAPI) +} diff --git a/providers/dns/manageengine/internal/client_test.go b/providers/dns/manageengine/internal/client_test.go new file mode 100644 index 000000000..25d1730f6 --- /dev/null +++ b/providers/dns/manageengine/internal/client_test.go @@ -0,0 +1,302 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(server.Client()) + + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader(). + WithAccept("application/json")) +} + +func TestClient_GetAllZones(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/domain", servermock.ResponseFromFixture("zone_domains_all.json")). + Build(t) + + groups, err := client.GetAllZones(t.Context()) + require.NoError(t, err) + + expected := []Zone{ + { + ZoneID: 1, + ZoneName: "test.com.", + ZoneTTL: 500, + ZoneTargeting: true, + Refresh: 43200, + Retry: 3600, + Expiry: 1209600, + Minimum: 180, + Org: 2, + NsID: 1, + Serial: 2022042206, + Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."}, + }, + { + ZoneID: 2, + ZoneName: "yourdomain.com.", + ZoneTTL: 1000, + Refresh: 43200, + Retry: 3600, + Expiry: 1209600, + Minimum: 180, + Org: 2, + Vanity: true, + NsID: 1, + Serial: 2022040608, + Nss: []string{"ns11.yourdomain.com.", "ns21.yourdomain.net.", "ns31.yourdomain.com.", "ns41.yourdomain.net."}, + }, + { + ZoneID: 20, + ZoneName: "hello45.com.", + ZoneTTL: 3000, + Refresh: 43200, + Retry: 3600, + Expiry: 1209600, + Minimum: 180, + Org: 2, + NsID: 1, + Serial: 2022040711, + Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."}, + }, + { + ZoneID: 22, + ZoneName: "zohoaccl.com.", + ZoneTTL: 300, + ZoneTargeting: true, + Refresh: 43200, + Retry: 3600, + Expiry: 1209600, + Minimum: 180, + Org: 2, + NsID: 1, + Serial: 2022042206, + Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."}, + }, + { + ZoneID: 23, + ZoneName: "zohocal.com.", + ZoneTTL: 300, + ZoneTargeting: true, + Refresh: 43200, + Retry: 3600, + Expiry: 1209600, + Minimum: 180, + Org: 2, + NsID: 1, + Serial: 2022041310, + Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."}, + }, + } + + assert.Equal(t, expected, groups) +} + +func TestClient_GetAllZones_error(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/domain", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + _, err := client.GetAllZones(t.Context()) + require.Error(t, err) + + require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.") +} + +func TestClient_GetAllZoneRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/domain/4/records/SPF_TXT", servermock.ResponseFromFixture("zone_records_all.json")). + Build(t) + + groups, err := client.GetAllZoneRecords(t.Context(), 4) + require.NoError(t, err) + + expected := []ZoneRecord{ + { + ZoneID: 4, + SpfTxtDomainID: 6, + DomainName: "spftest.example.com.", + DomainTTL: 300, + DomainLocationID: 1, + RecordType: "SPF", + Records: []Record{{ + ID: 1, + Values: []string{"necwcltpwxbz-noelget3jush-vop2xxvapot3eyq_0"}, + DomainID: 6, + }}, + }, + { + ZoneID: 4, + SpfTxtDomainID: 13, + DomainName: "txt.example.com.", + DomainTTL: 300, + DomainLocationID: 1, + RecordType: "TXT", + Records: []Record{{ + ID: 1, + Values: []string{"v=spf1include:transmail.netinclude:example.com~all", "c-68e3oc4trm8w7piplscg7vgojmtkjrnrabr4king8"}, + DomainID: 13, + }}, + }, + } + + assert.Equal(t, expected, groups) +} + +func TestClient_GetAllZoneRecords_error(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/domain/4/records/SPF_TXT", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + _, err := client.GetAllZoneRecords(t.Context(), 4) + require.Error(t, err) + + require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.") +} + +func TestClient_DeleteZoneRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /dns/domain/4/records/SPF_TXT/6", servermock.ResponseFromFixture("zone_record_delete.json")). + Build(t) + + err := client.DeleteZoneRecord(t.Context(), 4, 6) + require.NoError(t, err) +} + +func TestClient_DeleteZoneRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /dns/domain/4/records/SPF_TXT/6", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + err := client.DeleteZoneRecord(t.Context(), 4, 6) + require.Error(t, err) + + require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.") +} + +func TestClient_CreateZoneRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/domain/4/records/SPF_TXT/", + servermock.ResponseFromFixture("zone_record_create.json"), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + servermock.CheckForm().Strict(). + With("config", `[{"zone_id":1,"spf_txt_domain_id":2,"domain_name":"example.com","domain_ttl":120,"domain_location_id":3,"record_type":"TXT","records":[{"record_id":123,"value":["value1"],"domain_id":1}]}] +`)). + Build(t) + + record := ZoneRecord{ + ZoneID: 1, + SpfTxtDomainID: 2, + DomainName: "example.com", + DomainTTL: 120, + DomainLocationID: 3, + RecordType: "TXT", + Records: []Record{ + { + ID: 123, + Values: []string{"value1"}, + Disabled: false, + DomainID: 1, + }, + }, + } + + err := client.CreateZoneRecord(t.Context(), 4, record) + require.NoError(t, err) +} + +func TestClient_CreateZoneRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/domain/4/records/SPF_TXT/", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()). + Build(t) + + record := ZoneRecord{} + + err := client.CreateZoneRecord(t.Context(), 4, record) + require.Error(t, err) + + require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.") +} + +func TestClient_CreateZoneRecord_error_bad_request(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/domain/4/records/SPF_TXT/", + servermock.ResponseFromFixture("error_bad_request.json"). + WithStatusCode(http.StatusBadRequest), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()). + Build(t) + + record := ZoneRecord{} + + err := client.CreateZoneRecord(t.Context(), 4, record) + require.Error(t, err) + + require.EqualError(t, err, "[status code: 400] Invalid record format, Record should be in list.") +} + +func TestClient_UpdateZoneRecord(t *testing.T) { + client := mockBuilder(). + Route("PUT /dns/domain/4/records/SPF_TXT/6/", + servermock.ResponseFromFixture("zone_record_update.json"), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + servermock.CheckForm().Strict(). + With("config", `[{"zone_id":4,"spf_txt_domain_id":6,"records":null}] +`)). + Build(t) + + record := ZoneRecord{ + SpfTxtDomainID: 6, + ZoneID: 4, + } + + err := client.UpdateZoneRecord(t.Context(), record) + require.NoError(t, err) +} + +func TestClient_UpdateZoneRecord_error(t *testing.T) { + client := mockBuilder(). + Route("PUT /dns/domain/4/records/SPF_TXT/6/", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()). + Build(t) + + record := ZoneRecord{ + SpfTxtDomainID: 6, + ZoneID: 4, + } + + err := client.UpdateZoneRecord(t.Context(), record) + require.Error(t, err) + + require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.") +} diff --git a/providers/dns/manageengine/internal/fixtures/error.json b/providers/dns/manageengine/internal/fixtures/error.json new file mode 100644 index 000000000..5cd198670 --- /dev/null +++ b/providers/dns/manageengine/internal/fixtures/error.json @@ -0,0 +1,3 @@ +{ + "detail": "Authentication credentials were not provided." +} diff --git a/providers/dns/manageengine/internal/fixtures/error_bad_request.json b/providers/dns/manageengine/internal/fixtures/error_bad_request.json new file mode 100644 index 000000000..944cef6c0 --- /dev/null +++ b/providers/dns/manageengine/internal/fixtures/error_bad_request.json @@ -0,0 +1,3 @@ +{ + "error": "Invalid record format, Record should be in list." +} diff --git a/providers/dns/manageengine/internal/fixtures/zone_domains_all.json b/providers/dns/manageengine/internal/fixtures/zone_domains_all.json new file mode 100644 index 000000000..3e37f52a7 --- /dev/null +++ b/providers/dns/manageengine/internal/fixtures/zone_domains_all.json @@ -0,0 +1,146 @@ +[ + { + "zone_id": 1, + "zone_name": "test.com.", + "zone_ttl": 500, + "zone_type": 0, + "zone_targeting": true, + "zone_logging": "{}", + "zone_contact": "mathes.zoho.com", + "refresh": 43200, + "retry": 3600, + "expiry": 1209600, + "minimum": 180, + "org": 2, + "any_query": false, + "dnssec": true, + "vanity": false, + "ns_id": 1, + "serial": 2022042206, + "ns": [ + "ns11.zns-53.com.", + "ns21.zns-53.net.", + "ns31.zns-53.com.", + "ns41.zns-53.net." + ], + "contact_group": [ + "test_contact1", + "test_contact2" + ], + "ds": [ + { + "record_id": 59, + "keyTag": 36938, + "algorithm": 13, + "digestType": 1, + "digest": "e9f03d176455d5d16f826b69f9ecb11f59be35e7", + "domain_id": 30 + }, + { + "record_id": 60, + "keyTag": 36938, + "algorithm": 13, + "digestType": 2, + "digest": "7ea640a8668eafd9d89a9b2e9994f5fcfb1dee0668d1e93ba556aa57ac047f96", + "domain_id": 30 + } + ] + }, + { + "zone_id": 2, + "zone_name": "yourdomain.com.", + "zone_ttl": 1000, + "zone_type": 0, + "zone_targeting": false, + "zone_logging": "{}", + "zone_contact": "contact.yourdomain.com", + "refresh": 43200, + "retry": 3600, + "expiry": 1209600, + "minimum": 180, + "org": 2, + "any_query": false, + "dnssec": false, + "vanity": true, + "vanity_grp": "yourdomain", + "ns_id": 1, + "serial": 2022040608, + "ns": [ + "ns11.yourdomain.com.", + "ns21.yourdomain.net.", + "ns31.yourdomain.com.", + "ns41.yourdomain.net." + ] + }, + { + "zone_id": 20, + "zone_name": "hello45.com.", + "zone_ttl": 3000, + "zone_targeting": false, + "zone_logging": "{}", + "zone_contact": "mathes.zoho.com", + "refresh": 43200, + "retry": 3600, + "expiry": 1209600, + "minimum": 180, + "org": 2, + "any_query": false, + "dnssec": false, + "ns_id": 1, + "serial": 2022040711, + "ns": [ + "ns11.zns-53.com.", + "ns21.zns-53.net.", + "ns31.zns-53.com.", + "ns41.zns-53.net." + ] + }, + { + "zone_id": 22, + "zone_name": "zohoaccl.com.", + "zone_ttl": 300, + "zone_type": 0, + "zone_targeting": true, + "zone_logging": "{}", + "zone_contact": "networkone.zohocorp.com", + "refresh": 43200, + "retry": 3600, + "expiry": 1209600, + "minimum": 180, + "org": 2, + "any_query": false, + "dnssec": false, + "ns_id": 1, + "serial": 2022042206, + "ns": [ + "ns11.zns-53.com.", + "ns21.zns-53.net.", + "ns31.zns-53.com.", + "ns41.zns-53.net." + ] + }, + { + "zone_id": 23, + "zone_name": "zohocal.com.", + "zone_ttl": 300, + "zone_type": 0, + "zone_targeting": true, + "zone_logging": "{}", + "zone_contact": "mathes.zoho.com", + "refresh": 43200, + "retry": 3600, + "expiry": 1209600, + "minimum": 180, + "org": 2, + "any_query": false, + "dnssec": false, + "ns_id": 1, + "serial": 2022041310, + "ns": [ + "ns11.zns-53.com.", + "ns21.zns-53.net.", + "ns31.zns-53.com.", + "ns41.zns-53.net." + ] + } +] diff --git a/providers/dns/manageengine/internal/fixtures/zone_record_create.json b/providers/dns/manageengine/internal/fixtures/zone_record_create.json new file mode 100644 index 000000000..3fd216f2d --- /dev/null +++ b/providers/dns/manageengine/internal/fixtures/zone_record_create.json @@ -0,0 +1,3 @@ +{ + "message": "Record created successfully" +} diff --git a/providers/dns/manageengine/internal/fixtures/zone_record_delete.json b/providers/dns/manageengine/internal/fixtures/zone_record_delete.json new file mode 100644 index 000000000..c657d84ea --- /dev/null +++ b/providers/dns/manageengine/internal/fixtures/zone_record_delete.json @@ -0,0 +1,3 @@ +{ + "message": "Record deleted successfully" +} diff --git a/providers/dns/manageengine/internal/fixtures/zone_record_update.json b/providers/dns/manageengine/internal/fixtures/zone_record_update.json new file mode 100644 index 000000000..178c1fb0f --- /dev/null +++ b/providers/dns/manageengine/internal/fixtures/zone_record_update.json @@ -0,0 +1,3 @@ +{ + "message": "Record updated successfully" +} diff --git a/providers/dns/manageengine/internal/fixtures/zone_records_all.json b/providers/dns/manageengine/internal/fixtures/zone_records_all.json new file mode 100644 index 000000000..ae08a4c7e --- /dev/null +++ b/providers/dns/manageengine/internal/fixtures/zone_records_all.json @@ -0,0 +1,40 @@ +[ + { + "spf_txt_domain_id": 6, + "zone_id": 4, + "domain_name": "spftest.example.com.", + "domain_ttl": 300, + "domain_location_id": 1, + "record_type": "SPF", + "records": [ + { + "record_id": 1, + "value": [ + "necwcltpwxbz-noelget3jush-vop2xxvapot3eyq_0" + ], + "disabled": false, + "domain_id": 6 + } + ] + }, + { + "spf_txt_domain_id": 13, + "zone_id": 4, + "domain_name": "txt.example.com.", + "domain_ttl": 300, + "domain_maxhost": 1, + "domain_location_id": 1, + "record_type": "TXT", + "records": [ + { + "record_id": 1, + "value": [ + "v=spf1include:transmail.netinclude:example.com~all", + "c-68e3oc4trm8w7piplscg7vgojmtkjrnrabr4king8" + ], + "disabled": false, + "domain_id": 13 + } + ] + } +] diff --git a/providers/dns/manageengine/internal/identity.go b/providers/dns/manageengine/internal/identity.go new file mode 100644 index 000000000..ec28121e4 --- /dev/null +++ b/providers/dns/manageengine/internal/identity.go @@ -0,0 +1,20 @@ +package internal + +import ( + "context" + "net/http" + + "golang.org/x/oauth2/clientcredentials" +) + +const defaultAuthURL = "https://clouddns.manageengine.com/oauth2/token/" + +func CreateOAuthClient(ctx context.Context, clientID, clientSecret string) *http.Client { + config := &clientcredentials.Config{ + TokenURL: defaultAuthURL, + ClientID: clientID, + ClientSecret: clientSecret, + } + + return config.Client(ctx) +} diff --git a/providers/dns/manageengine/internal/types.go b/providers/dns/manageengine/internal/types.go new file mode 100644 index 000000000..7a039f67f --- /dev/null +++ b/providers/dns/manageengine/internal/types.go @@ -0,0 +1,63 @@ +package internal + +import ( + "strings" +) + +type APIError struct { + Message string `json:"error"` + Detail string `json:"detail"` +} + +func (a *APIError) Error() string { + var msg []string + + if a.Message != "" { + msg = append(msg, a.Message) + } + + if a.Detail != "" { + msg = append(msg, a.Detail) + } + + return strings.Join(msg, " ") +} + +type APIResponse struct { + Message string `json:"message,omitempty"` +} + +type ZoneRecord struct { + ZoneID int `json:"zone_id,omitempty"` + SpfTxtDomainID int `json:"spf_txt_domain_id,omitempty"` + DomainName string `json:"domain_name,omitempty"` + DomainTTL int `json:"domain_ttl,omitempty"` + DomainLocationID int `json:"domain_location_id,omitempty"` + RecordType string `json:"record_type,omitempty"` + Records []Record `json:"records"` +} + +type Record struct { + ID int `json:"record_id,omitempty"` + Values []string `json:"value,omitempty"` + Disabled bool `json:"disabled,omitempty"` + DomainID int `json:"domain_id,omitempty"` +} + +type Zone struct { + ZoneID int `json:"zone_id"` + ZoneName string `json:"zone_name"` + ZoneTTL int `json:"zone_ttl"` + ZoneType int `json:"zone_type,omitempty"` + ZoneTargeting bool `json:"zone_targeting"` + Refresh int `json:"refresh"` + Retry int `json:"retry"` + Expiry int `json:"expiry"` + Minimum int `json:"minimum"` + Org int `json:"org"` + AnyQuery bool `json:"any_query"` + Vanity bool `json:"vanity,omitempty"` + NsID int `json:"ns_id"` + Serial int `json:"serial"` + Nss []string `json:"ns"` +} diff --git a/providers/dns/manageengine/manageengine.go b/providers/dns/manageengine/manageengine.go new file mode 100644 index 000000000..76b6644c0 --- /dev/null +++ b/providers/dns/manageengine/manageengine.go @@ -0,0 +1,266 @@ +// Package manageengine implements a DNS provider for solving the DNS-01 challenge using ManageEngine CloudDNS. +package manageengine + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/manageengine/internal" +) + +// Environment variables names. +const ( + envNamespace = "MANAGEENGINE_" + + EnvClientID = envNamespace + "CLIENT_ID" + EnvClientSecret = envNamespace + "CLIENT_SECRET" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + ClientID string + ClientSecret string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for ManageEngine CloudDNS. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvClientID, EnvClientSecret) + if err != nil { + return nil, fmt.Errorf("manageengine: %w", err) + } + + config := NewDefaultConfig() + config.ClientID = values[EnvClientID] + config.ClientSecret = values[EnvClientSecret] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for ManageEngine CloudDNS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("manageengine: the configuration of the DNS provider is nil") + } + + if config.ClientID == "" || config.ClientSecret == "" { + return nil, errors.New("manageengine: credentials missing") + } + + return &DNSProvider{ + config: config, + client: internal.NewClient( + clientdebug.Wrap( + internal.CreateOAuthClient(context.Background(), config.ClientID, config.ClientSecret), + ), + ), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("manageengine: could not find zone for domain %q: %w", domain, err) + } + + zoneID, err := d.findZoneID(ctx, authZone) + if err != nil { + return fmt.Errorf("manageengine: find zone ID: %w", err) + } + + zoneRecord, err := d.findZoneRecord(ctx, zoneID, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("manageengine: find zone record: %w", err) + } + + // Update the existing zone record. + if zoneRecord != nil { + for _, record := range zoneRecord.Records { + if slices.Contains(record.Values, info.Value) { + continue + } + + zr := internal.ZoneRecord{ + ZoneID: zoneID, + SpfTxtDomainID: zoneRecord.SpfTxtDomainID, + DomainName: info.EffectiveFQDN, + DomainTTL: d.config.TTL, + RecordType: "TXT", + Records: []internal.Record{{ + Values: append(record.Values, info.Value), + DomainID: zoneRecord.SpfTxtDomainID, + }}, + } + + // Update the zone record. + err = d.client.UpdateZoneRecord(ctx, zr) + if err != nil { + return fmt.Errorf("manageengine: update zone record: %w", err) + } + + return nil + } + + return errors.New("manageengine: zone already contains the TXT record value") + } + + // Create a new zone record. + record := internal.ZoneRecord{ + ZoneID: zoneID, + DomainName: info.EffectiveFQDN, + DomainTTL: d.config.TTL, + RecordType: "TXT", + Records: []internal.Record{{ + Values: []string{info.Value}, + }}, + } + + err = d.client.CreateZoneRecord(ctx, zoneID, record) + if err != nil { + return fmt.Errorf("manageengine: create zone record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("manageengine: could not find zone for domain %q: %w", domain, err) + } + + zoneID, err := d.findZoneID(ctx, authZone) + if err != nil { + return fmt.Errorf("manageengine: find zone ID: %w", err) + } + + zoneRecord, err := d.findZoneRecord(ctx, zoneID, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("manageengine: find zone record: %w", err) + } + + for _, record := range zoneRecord.Records { + if !slices.Contains(record.Values, info.Value) { + continue + } + + // Delete the zone record. + if len(record.Values) <= 1 { + err = d.client.DeleteZoneRecord(ctx, zoneID, zoneRecord.SpfTxtDomainID) + if err != nil { + return fmt.Errorf("manageengine: delete zone record: %w", err) + } + + return nil + } + + // Update the zone record. + var values []string + + for _, value := range record.Values { + if value != info.Value { + values = append(values, value) + } + } + + zr := internal.ZoneRecord{ + ZoneID: zoneID, + SpfTxtDomainID: zoneRecord.SpfTxtDomainID, + DomainName: info.EffectiveFQDN, + DomainTTL: d.config.TTL, + RecordType: "TXT", + Records: []internal.Record{{ + Values: values, + DomainID: zoneRecord.SpfTxtDomainID, + }}, + } + + err = d.client.UpdateZoneRecord(ctx, zr) + if err != nil { + return fmt.Errorf("manageengine: create zone record: %w", err) + } + + return nil + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) findZoneID(ctx context.Context, authZone string) (int, error) { + zones, err := d.client.GetAllZones(ctx) + if err != nil { + return 0, fmt.Errorf("get all zone groups: %w", err) + } + + for _, zone := range zones { + if strings.EqualFold(zone.ZoneName, authZone) { + return zone.ZoneID, nil + } + } + + return 0, fmt.Errorf("zone not found %s", authZone) +} + +func (d *DNSProvider) findZoneRecord(ctx context.Context, zoneID int, fqdn string) (*internal.ZoneRecord, error) { + zoneRecords, err := d.client.GetAllZoneRecords(ctx, zoneID) + if err != nil { + return nil, fmt.Errorf("get all zone records: %w", err) + } + + for _, zoneRecord := range zoneRecords { + if !strings.EqualFold(zoneRecord.DomainName, fqdn) { + continue + } + + if strings.EqualFold(zoneRecord.RecordType, "TXT") { + return &zoneRecord, nil + } + } + + return nil, nil +} diff --git a/providers/dns/manageengine/manageengine.toml b/providers/dns/manageengine/manageengine.toml new file mode 100644 index 000000000..43a782841 --- /dev/null +++ b/providers/dns/manageengine/manageengine.toml @@ -0,0 +1,23 @@ +Name = "ManageEngine CloudDNS" +Description = '''''' +URL = "https://clouddns.manageengine.com" +Code = "manageengine" +Since = "v4.21.0" + +Example = ''' +MANAGEENGINE_CLIENT_ID="xxx" \ +MANAGEENGINE_CLIENT_SECRET="yyy" \ +lego --dns manageengine -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + MANAGEENGINE_CLIENT_ID = "Client ID" + MANAGEENGINE_CLIENT_SECRET = "Client Secret" + [Configuration.Additional] + MANAGEENGINE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + MANAGEENGINE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + MANAGEENGINE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + +[Links] + API = "https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation" diff --git a/providers/dns/manageengine/manageengine_test.go b/providers/dns/manageengine/manageengine_test.go new file mode 100644 index 000000000..215de68dd --- /dev/null +++ b/providers/dns/manageengine/manageengine_test.go @@ -0,0 +1,146 @@ +package manageengine + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvClientID, EnvClientSecret).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvClientID: "abc", + EnvClientSecret: "secret", + }, + }, + { + desc: "missing client ID", + envVars: map[string]string{ + EnvClientID: "", + EnvClientSecret: "secret", + }, + expected: "manageengine: some credentials information are missing: MANAGEENGINE_CLIENT_ID", + }, + { + desc: "missing client secret", + envVars: map[string]string{ + EnvClientID: "abc", + EnvClientSecret: "", + }, + expected: "manageengine: some credentials information are missing: MANAGEENGINE_CLIENT_SECRET", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "manageengine: some credentials information are missing: MANAGEENGINE_CLIENT_ID,MANAGEENGINE_CLIENT_SECRET", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + clientID string + clientSecret string + expected string + }{ + { + desc: "success", + clientID: "abc", + clientSecret: "secret", + }, + { + desc: "missing client ID", + clientSecret: "secret", + expected: "manageengine: credentials missing", + }, + { + desc: "missing client secret", + clientID: "abc", + expected: "manageengine: credentials missing", + }, + { + desc: "missing credentials", + expected: "manageengine: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.ClientID = test.clientID + config.ClientSecret = test.clientSecret + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/manual/manual.go b/providers/dns/manual/manual.go new file mode 100644 index 000000000..2985bc595 --- /dev/null +++ b/providers/dns/manual/manual.go @@ -0,0 +1,13 @@ +package manual + +import ( + "github.com/go-acme/lego/v4/challenge/dns01" +) + +// DNSProvider is an implementation of the ChallengeProvider interface. +type DNSProvider = dns01.DNSProviderManual + +// NewDNSProvider returns a DNSProvider instance. +func NewDNSProvider() (*DNSProvider, error) { + return &DNSProvider{}, nil +} diff --git a/docs/content/dns/manual.md b/providers/dns/manual/manual.toml similarity index 76% rename from docs/content/dns/manual.md rename to providers/dns/manual/manual.toml index 3f9cf0a8e..fc47a8fae 100644 --- a/docs/content/dns/manual.md +++ b/providers/dns/manual/manual.toml @@ -1,24 +1,19 @@ ---- -title: "Manual" -date: 2019-03-03T16:39:46+01:00 -draft: false -slug: manual -dnsprovider: - since: v0.3.0 - code: manual - url: ---- +Name = "Manual" +Description = '''Solving the DNS-01 challenge using CLI prompt.''' +Code = "manual" +Since = "v0.3.0" -Solving the DNS-01 challenge using CLI prompt. - - +Example = ''' +lego --dns manual -d '*.example.com' -d example.com run +''' +Additional = ''' ## Example To start using the CLI prompt "provider", start lego with `--dns manual`: ```console -$ lego --email "you@example.com" --domains="example.com" --dns "manual" run +$ lego --dns manual -d example.com run ``` What follows are a few log print-outs, interspersed with some prompts, asking for you to do perform some actions: @@ -36,13 +31,13 @@ If you accept the linked Terms of Service, hit `Enter`. [INFO] acme: Registering account for you@example.com !!!! HEADS UP !!!! - Your account credentials have been saved in your Let's Encrypt - configuration directory at "./.lego/accounts". +Your account credentials have been saved in your +configuration directory at "./.lego/accounts". - You should make a secure backup of this folder now. This - configuration directory will also contain certificates and - private keys obtained from Let's Encrypt so making regular - backups of this folder is ideal. +You should make a secure backup of this folder now. This +configuration directory will also contain private keys +generated by lego and certificates obtained from the ACME +server. Making regular backups of this folder is ideal. [INFO] [example.com] acme: Obtaining bundled SAN certificate [INFO] [example.com] AuthURL: https://acme-v02.api.letsencrypt.org/acme/authz-v3/2345678901 [INFO] [example.com] acme: Could not find solver for: tls-alpn-01 @@ -70,3 +65,5 @@ _acme-challenge.example.com. 120 IN TXT "hX0dPkG6Gfs9hUvBAchQclkyyoEKbShbpvJ9mY5 ``` As mentioned, you can now remove the TXT record again. + +''' diff --git a/challenge/dns01/dns_challenge_manual_test.go b/providers/dns/manual/manual_test.go similarity index 85% rename from challenge/dns01/dns_challenge_manual_test.go rename to providers/dns/manual/manual_test.go index cfc728aca..7badd4b8b 100644 --- a/challenge/dns01/dns_challenge_manual_test.go +++ b/providers/dns/manual/manual_test.go @@ -1,4 +1,4 @@ -package dns01 +package manual import ( "io" @@ -10,6 +10,7 @@ import ( func TestDNSProviderManual(t *testing.T) { backupStdin := os.Stdin + defer func() { os.Stdin = backupStdin }() testCases := []struct { @@ -30,9 +31,10 @@ func TestDNSProviderManual(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - file, err := os.CreateTemp("", "lego_test") + file, err := os.CreateTemp(t.TempDir(), "lego_test") require.NoError(t, err) - defer func() { _ = os.Remove(file.Name()) }() + + t.Cleanup(func() { _ = file.Close() }) _, err = file.WriteString(test.input) require.NoError(t, err) @@ -42,7 +44,7 @@ func TestDNSProviderManual(t *testing.T) { os.Stdin = file - manualProvider, err := NewDNSProviderManual() + manualProvider, err := NewDNSProvider() require.NoError(t, err) err = manualProvider.Present("example.com", "", "") diff --git a/providers/dns/metaname/metaname.go b/providers/dns/metaname/metaname.go index ab5a4dff2..d6e962024 100644 --- a/providers/dns/metaname/metaname.go +++ b/providers/dns/metaname/metaname.go @@ -8,6 +8,7 @@ import ( "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/nzdjb/go-metaname" @@ -25,6 +26,8 @@ const ( EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { AccountReference string @@ -76,6 +79,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config.AccountReference == "" { return nil, errors.New("metaname: missing account reference") } + if config.APIKey == "" { return nil, errors.New("metaname: missing api key") } @@ -149,6 +153,10 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("metaname: delete record: %w", err) } + d.recordsMu.Lock() + delete(d.records, token) + d.recordsMu.Unlock() + return nil } diff --git a/providers/dns/metaname/metaname.toml b/providers/dns/metaname/metaname.toml index 142f06639..654dcaed0 100644 --- a/providers/dns/metaname/metaname.toml +++ b/providers/dns/metaname/metaname.toml @@ -7,7 +7,7 @@ Since = "v4.13.0" Example = ''' METANAME_ACCOUNT_REFERENCE=xxxx \ METANAME_API_KEY=yyyyyyy \ -lego --email you@example.com --dns metaname -d '*.example.com' -d example.com run +lego --dns metaname -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,9 +15,9 @@ lego --email you@example.com --dns metaname -d '*.example.com' -d example.com ru METANAME_ACCOUNT_REFERENCE = "The four-digit reference of a Metaname account" METANAME_API_KEY = "API Key" [Configuration.Additional] - METANAME_POLLING_INTERVAL = "Time between DNS propagation check" - METANAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - METANAME_TTL = "The TTL of the TXT record used for the DNS challenge" + METANAME_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + METANAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + METANAME_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" [Links] API = "https://metaname.net/api/1.1/doc" diff --git a/providers/dns/metaname/metaname_test.go b/providers/dns/metaname/metaname_test.go index 174af4014..855fc493d 100644 --- a/providers/dns/metaname/metaname_test.go +++ b/providers/dns/metaname/metaname_test.go @@ -51,6 +51,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -122,6 +123,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -135,6 +137,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/metaregistrar/internal/client.go b/providers/dns/metaregistrar/internal/client.go new file mode 100644 index 000000000..df99d81ba --- /dev/null +++ b/providers/dns/metaregistrar/internal/client.go @@ -0,0 +1,131 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const defaultBaseURL = "https://api.metaregistrar.com" + +const tokenHeader = "token" + +// Client is a client to interact with the Metaregistrar API. +type Client struct { + token string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(token string) (*Client, error) { + if token == "" { + return nil, errors.New("token missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + token: token, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// UpdateDNSZone updates the DNS zone for a domain. +// To add or remove a TXT record we make a PATCH request. +// https://metaregistrar.dev/docu/metaapi/requests/patch_Update_dns_zone.html +func (c *Client) UpdateDNSZone(ctx context.Context, domain string, updateRequest DNSZoneUpdateRequest) (*DNSZoneUpdateResponse, error) { + endpoint := c.baseURL.JoinPath("dnszone", domain) + + req, err := newJSONRequest(ctx, http.MethodPatch, endpoint, updateRequest) + if err != nil { + return nil, err + } + + result := &DNSZoneUpdateResponse{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +func (c *Client) do(req *http.Request, result any) error { + req.Header.Add(tokenHeader, c.token) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/metaregistrar/internal/client_test.go b/providers/dns/metaregistrar/internal/client_test.go new file mode 100644 index 000000000..33e92cd7b --- /dev/null +++ b/providers/dns/metaregistrar/internal/client_test.go @@ -0,0 +1,98 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With(tokenHeader, "secret")) +} + +func TestClient_UpdateDNSZone(t *testing.T) { + client := mockBuilder(). + Route("PATCH /dnszone/example.com", + servermock.ResponseFromFixture("update-dns-zone.json"), + servermock.CheckRequestJSONBody(`{"add":[{"name":"@","type":"TXT","ttl":60,"content":"value"}]}`)). + Build(t) + + updateRequest := DNSZoneUpdateRequest{ + Add: []Record{{ + Name: "@", + Type: "TXT", + TTL: 60, + Content: "value", + }}, + } + + response, err := client.UpdateDNSZone(t.Context(), "example.com", updateRequest) + require.NoError(t, err) + + expected := &DNSZoneUpdateResponse{ + ResponseID: "mapi1_cb46ad8790b62b76535bd3102bd282aec83b894c", + Status: "ok", + Message: "Command completed successfully", + } + + assert.Equal(t, expected, response) +} + +func TestClient_UpdateDNSZone_error(t *testing.T) { + testCases := []struct { + desc string + filename string + expected string + }{ + { + desc: "authentication error", + filename: "error.json", + expected: "invalid_token: the supplied token is invalid", + }, + { + desc: "API error", + filename: "error-response.json", + expected: "error: does_not_exist: This server does not exist", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + client := mockBuilder(). + Route("PATCH /dnszone/example.com", + servermock.ResponseFromFixture(test.filename). + WithStatusCode(http.StatusUnprocessableEntity)). + Build(t) + + updateRequest := DNSZoneUpdateRequest{ + Add: []Record{{ + Name: "@", + Type: "TXT", + TTL: 60, + Content: "value", + }}, + } + + _, err := client.UpdateDNSZone(t.Context(), "example.com", updateRequest) + require.EqualError(t, err, test.expected) + }) + } +} diff --git a/providers/dns/metaregistrar/internal/fixtures/error-response.json b/providers/dns/metaregistrar/internal/fixtures/error-response.json new file mode 100644 index 000000000..8fa5a5ff3 --- /dev/null +++ b/providers/dns/metaregistrar/internal/fixtures/error-response.json @@ -0,0 +1,6 @@ +{ + "responseId": "1_0a407cb0634a56374ba80f863fda53ae37fd0042", + "status": "error", + "errorCode": "does_not_exist", + "errorMessage": "This server does not exist" +} diff --git a/providers/dns/metaregistrar/internal/fixtures/error.json b/providers/dns/metaregistrar/internal/fixtures/error.json new file mode 100644 index 000000000..c76a32fc8 --- /dev/null +++ b/providers/dns/metaregistrar/internal/fixtures/error.json @@ -0,0 +1,4 @@ +{ + "error": "invalid_token", + "message": "the supplied token is invalid" +} \ No newline at end of file diff --git a/providers/dns/metaregistrar/internal/fixtures/update-dns-zone.json b/providers/dns/metaregistrar/internal/fixtures/update-dns-zone.json new file mode 100644 index 000000000..b4977272a --- /dev/null +++ b/providers/dns/metaregistrar/internal/fixtures/update-dns-zone.json @@ -0,0 +1,5 @@ +{ + "responseId": "mapi1_cb46ad8790b62b76535bd3102bd282aec83b894c", + "status": "ok", + "message": "Command completed successfully" +} diff --git a/providers/dns/metaregistrar/internal/types.go b/providers/dns/metaregistrar/internal/types.go new file mode 100644 index 000000000..d8b6b3f87 --- /dev/null +++ b/providers/dns/metaregistrar/internal/types.go @@ -0,0 +1,67 @@ +package internal + +import ( + "strings" +) + +// APIError It's a mix of documented and undocumented fields. +// Note: the documentation is inconsistent: the names of property are not the same as the JSON sample. +// https://metaregistrar.dev/docu/metaapi/requests/response_ErrorResponse.html +type APIError struct { + ResponseID string `json:"responseId,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + Err string `json:"error,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` +} + +func (e *APIError) Error() string { + var msg []string + + if e.Status != "" { + msg = append(msg, e.Status) + } + + if e.Err != "" { + msg = append(msg, e.Err) + } + + if e.ErrorCode != "" { + msg = append(msg, e.ErrorCode) + } + + if e.Message != "" { + msg = append(msg, e.Message) + } + + if e.ErrorMessage != "" { + msg = append(msg, e.ErrorMessage) + } + + return strings.Join(msg, ": ") +} + +type Record struct { + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + TTL int `json:"ttl,omitempty"` + Content string `json:"content,omitempty"` + Priority int `json:"priority,omitempty"` + Disabled bool `json:"disabled,omitempty"` +} + +// DNSZoneUpdateRequest is the representation of DnszoneUpdateRequest object. +// https://metaregistrar.dev/docu/metaapi/requests/request_DnszoneUpdateRequest.html +type DNSZoneUpdateRequest struct { + Add []Record `json:"add,omitempty"` + Remove []Record `json:"rem,omitempty"` +} + +// DNSZoneUpdateResponse is the representation of DnszoneUpdateResponse object. +// https://metaregistrar.dev/docu/metaapi/requests/response_DnszoneUpdateResponse.html +type DNSZoneUpdateResponse struct { + ResponseID string `json:"responseId,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` +} diff --git a/providers/dns/metaregistrar/metaregistrar.go b/providers/dns/metaregistrar/metaregistrar.go new file mode 100644 index 000000000..7a601ef21 --- /dev/null +++ b/providers/dns/metaregistrar/metaregistrar.go @@ -0,0 +1,150 @@ +// Package metaregistrar implements a DNS provider for solving the DNS-01 challenge using Metaregistrar. +package metaregistrar + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/metaregistrar/internal" +) + +// Environment variables names. +const ( + envNamespace = "METAREGISTRAR_" + + EnvToken = envNamespace + "API_TOKEN" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIToken string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Metaregistrar. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvToken) + if err != nil { + return nil, fmt.Errorf("metaregistrar: %w", err) + } + + config := NewDefaultConfig() + config.APIToken = values[EnvToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Metaregistrar. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("metaregistrar: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.APIToken) + if err != nil { + return nil, fmt.Errorf("metaregistrar: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("metaregistrar: could not find zone for domain %q: %w", domain, err) + } + + updateRequest := internal.DNSZoneUpdateRequest{ + Add: []internal.Record{{ + Name: dns01.UnFqdn(info.EffectiveFQDN), + Type: "TXT", + TTL: d.config.TTL, + Content: info.Value, + }}, + } + + _, err = d.client.UpdateDNSZone(context.Background(), dns01.UnFqdn(authZone), updateRequest) + if err != nil { + return fmt.Errorf("metaregistrar: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("metaregistrar: could not find zone for domain %q: %w", domain, err) + } + + updateRequest := internal.DNSZoneUpdateRequest{ + Remove: []internal.Record{{ + Name: dns01.UnFqdn(info.EffectiveFQDN), + Type: "TXT", + TTL: d.config.TTL, + Content: strconv.Quote(info.Value), + }}, + } + + _, err = d.client.UpdateDNSZone(context.Background(), dns01.UnFqdn(authZone), updateRequest) + if err != nil { + return fmt.Errorf("metaregistrar: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/metaregistrar/metaregistrar.toml b/providers/dns/metaregistrar/metaregistrar.toml new file mode 100644 index 000000000..e505e0ce2 --- /dev/null +++ b/providers/dns/metaregistrar/metaregistrar.toml @@ -0,0 +1,22 @@ +Name = "Metaregistrar" +Description = '''''' +URL = "https://metaregistrar.com/" +Code = "metaregistrar" +Since = "v4.23.0" + +Example = ''' +METAREGISTRAR_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns metaregistrar -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + METAREGISTRAR_API_TOKEN = "The API token" + [Configuration.Additional] + METAREGISTRAR_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + METAREGISTRAR_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + METAREGISTRAR_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + METAREGISTRAR_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://metaregistrar.dev/docu/metaapi/" diff --git a/providers/dns/metaregistrar/metaregistrar_test.go b/providers/dns/metaregistrar/metaregistrar_test.go new file mode 100644 index 000000000..aa9bbbb58 --- /dev/null +++ b/providers/dns/metaregistrar/metaregistrar_test.go @@ -0,0 +1,116 @@ +package metaregistrar + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvToken: "token", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "metaregistrar: some credentials information are missing: METAREGISTRAR_API_TOKEN", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiToken string + expected string + }{ + { + desc: "success", + apiToken: "token", + }, + { + desc: "missing credentials", + expected: "metaregistrar: token missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIToken = test.apiToken + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/mijnhost/internal/client.go b/providers/dns/mijnhost/internal/client.go index 82bdcfeb9..a51233211 100644 --- a/providers/dns/mijnhost/internal/client.go +++ b/providers/dns/mijnhost/internal/client.go @@ -38,7 +38,7 @@ func NewClient(apiKey string) *Client { // ListDomains Retrieve all domains from an account. // https://mijn.host/api/doc/api-3563872 -func (c Client) ListDomains(ctx context.Context) ([]Domain, error) { +func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { endpoint := c.baseURL.JoinPath("domains") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -47,6 +47,7 @@ func (c Client) ListDomains(ctx context.Context) ([]Domain, error) { } var results Response[DomainData] + err = c.do(req, &results) if err != nil { return nil, err @@ -57,7 +58,7 @@ func (c Client) ListDomains(ctx context.Context) ([]Domain, error) { // GetRecords Retrieve DNS records of specific domain. // https://mijn.host/api/doc/api-3563906 -func (c Client) GetRecords(ctx context.Context, domain string) ([]Record, error) { +func (c *Client) GetRecords(ctx context.Context, domain string) ([]Record, error) { endpoint := c.baseURL.JoinPath("domains", domain, "dns") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -66,6 +67,7 @@ func (c Client) GetRecords(ctx context.Context, domain string) ([]Record, error) } var results Response[RecordData] + err = c.do(req, &results) if err != nil { return nil, err @@ -76,7 +78,7 @@ func (c Client) GetRecords(ctx context.Context, domain string) ([]Record, error) // UpdateRecords Update DNS records of specific domain. // https://mijn.host/api/doc/api-3563907 -func (c Client) UpdateRecords(ctx context.Context, domain string, records []Record) error { +func (c *Client) UpdateRecords(ctx context.Context, domain string, records []Record) error { endpoint := c.baseURL.JoinPath("domains", domain, "dns") req, err := newJSONRequest(ctx, http.MethodPut, endpoint, RecordData{Records: records}) @@ -92,7 +94,7 @@ func (c Client) UpdateRecords(ctx context.Context, domain string, records []Reco return nil } -func (c Client) do(req *http.Request, result any) error { +func (c *Client) do(req *http.Request, result any) error { req.Header.Set(authorizationHeader, c.apiKey) resp, err := c.HTTPClient.Do(req) @@ -151,6 +153,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError + err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/mijnhost/internal/client_test.go b/providers/dns/mijnhost/internal/client_test.go index 876ca5e1c..208616541 100644 --- a/providers/dns/mijnhost/internal/client_test.go +++ b/providers/dns/mijnhost/internal/client_test.go @@ -1,72 +1,37 @@ package internal import ( - "context" - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const apiKey = "secret" -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(apiKey) + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(apiKey) - client.baseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func testHandler(filename string, method string, statusCode int) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != apiKey { - http.Error(rw, "invalid Authorization header", http.StatusUnauthorized) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With(authorizationHeader, apiKey), + ) } func TestClient_ListDomains(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /domains", servermock.ResponseFromFixture("list-domains.json")). + Build(t) - mux.HandleFunc("/domains", testHandler("./list-domains.json", http.MethodGet, http.StatusOK)) - - domains, err := client.ListDomains(context.Background()) + domains, err := client.ListDomains(t.Context()) require.NoError(t, err) expected := []Domain{{ @@ -82,11 +47,11 @@ func TestClient_ListDomains(t *testing.T) { } func TestClient_GetRecords(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /domains/example.com/dns", servermock.ResponseFromFixture("get-dns-records.json")). + Build(t) - mux.HandleFunc("/domains/example.com/dns", testHandler("./get-dns-records.json", http.MethodGet, http.StatusOK)) - - records, err := client.GetRecords(context.Background(), "example.com") + records, err := client.GetRecords(t.Context(), "example.com") require.NoError(t, err) expected := []Record{ @@ -120,10 +85,19 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_UpdateRecords(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("PUT /domains/example.com/dns", + servermock.ResponseFromFixture("update-dns-records.json"), + servermock.CheckRequestJSONBody(`{"records":[{"type":"TXT","name":"foo","value":"value1","ttl":120}]}`)). + Build(t) - mux.HandleFunc("/domains/example.com/dns", testHandler("./update-dns-records.json", http.MethodPut, http.StatusOK)) + records := []Record{{ + Type: "TXT", + Name: "foo", + Value: "value1", + TTL: 120, + }} - err := client.UpdateRecords(context.Background(), "example.com", nil) + err := client.UpdateRecords(t.Context(), "example.com", records) require.NoError(t, err) } diff --git a/providers/dns/mijnhost/mijnhost.go b/providers/dns/mijnhost/mijnhost.go index 4d2cc1b39..adb3e9ce3 100644 --- a/providers/dns/mijnhost/mijnhost.go +++ b/providers/dns/mijnhost/mijnhost.go @@ -8,10 +8,11 @@ import ( "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/mijnhost/internal" - "github.com/miekg/dns" ) // Environment variables names. @@ -27,6 +28,10 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const txtType = "TXT" + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -82,6 +87,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client := internal.NewClient(config.APIKey) + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -102,9 +113,11 @@ func (d *DNSProvider) Sequential() time.Duration { // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) - domains, err := d.client.ListDomains(context.Background()) + domains, err := d.client.ListDomains(ctx) if err != nil { return fmt.Errorf("mijnhost: list domains: %w", err) } @@ -114,7 +127,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("mijnhost: find domain: %w", err) } - records, err := d.client.GetRecords(context.Background(), dom.Domain) + records, err := d.client.GetRecords(ctx, dom.Domain) if err != nil { return fmt.Errorf("mijnhost: get records: %w", err) } @@ -125,7 +138,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { } record := internal.Record{ - Type: "TXT", + Type: txtType, Name: subDomain, Value: info.Value, TTL: d.config.TTL, @@ -134,12 +147,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // mijn.host doesn't support multiple values for a domain, // so we removed existing record for the subdomain. cleanedRecords := filterRecords(records, func(record internal.Record) bool { - return record.Name == subDomain || record.Name == dns01.UnFqdn(info.EffectiveFQDN) + return record.Type == txtType && (record.Name == subDomain || record.Name == dns01.UnFqdn(info.EffectiveFQDN)) }) cleanedRecords = append(cleanedRecords, record) - err = d.client.UpdateRecords(context.Background(), dom.Domain, cleanedRecords) + err = d.client.UpdateRecords(ctx, dom.Domain, cleanedRecords) if err != nil { return fmt.Errorf("mijnhost: update records: %w", err) } @@ -149,9 +162,11 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // CleanUp removes the TXT record. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) - domains, err := d.client.ListDomains(context.Background()) + domains, err := d.client.ListDomains(ctx) if err != nil { return fmt.Errorf("mijnhost: list domains: %w", err) } @@ -161,16 +176,16 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("mijnhost: find domain: %w", err) } - records, err := d.client.GetRecords(context.Background(), dom.Domain) + records, err := d.client.GetRecords(ctx, dom.Domain) if err != nil { return fmt.Errorf("mijnhost: get records: %w", err) } cleanedRecords := filterRecords(records, func(record internal.Record) bool { - return record.Value == info.Value + return record.Type == txtType && record.Value == info.Value }) - err = d.client.UpdateRecords(context.Background(), dom.Domain, cleanedRecords) + err = d.client.UpdateRecords(ctx, dom.Domain, cleanedRecords) if err != nil { return fmt.Errorf("mijnhost: update records: %w", err) } @@ -179,11 +194,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } func findDomain(domains []internal.Domain, fqdn string) (internal.Domain, error) { - labelIndexes := dns.Split(fqdn) - - for _, index := range labelIndexes { - domain := dns01.UnFqdn(fqdn[index:]) - + for domain := range dns01.UnFqdnDomainsSeq(fqdn) { for _, dom := range domains { if dom.Domain == domain { return dom, nil diff --git a/providers/dns/mijnhost/mijnhost.toml b/providers/dns/mijnhost/mijnhost.toml index 7cea55a18..416fdde53 100644 --- a/providers/dns/mijnhost/mijnhost.toml +++ b/providers/dns/mijnhost/mijnhost.toml @@ -6,18 +6,18 @@ Since = "v4.18.0" Example = ''' MIJNHOST_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ -lego --email you@example.com --dns mijnhost -d '*.example.com' -d example.com run +lego --dns mijnhost -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] MIJNHOST_API_KEY = "The API key" [Configuration.Additional] - MIJNHOST_POLLING_INTERVAL = "Time between DNS propagation check" - MIJNHOST_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - MIJNHOST_SEQUENCE_INTERVAL = "Time between sequential requests" - MIJNHOST_TTL = "The TTL of the TXT record used for the DNS challenge" - MIJNHOST_HTTP_TIMEOUT = "API request timeout" + MIJNHOST_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + MIJNHOST_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + MIJNHOST_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" + MIJNHOST_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + MIJNHOST_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://mijn.host/api/doc/" diff --git a/providers/dns/mijnhost/mijnhost_test.go b/providers/dns/mijnhost/mijnhost_test.go index a48f84ca8..c87ae0a40 100644 --- a/providers/dns/mijnhost/mijnhost_test.go +++ b/providers/dns/mijnhost/mijnhost_test.go @@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -94,6 +95,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -107,6 +109,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/mittwald/internal/client.go b/providers/dns/mittwald/internal/client.go index 712caf8df..2b1564dc1 100644 --- a/providers/dns/mittwald/internal/client.go +++ b/providers/dns/mittwald/internal/client.go @@ -38,7 +38,7 @@ func NewClient(token string) *Client { // ListDomains List Domains. // https://api.mittwald.de/v2/docs/#/Domain/domain-list-domains -func (c Client) ListDomains(ctx context.Context) ([]Domain, error) { +func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { endpoint := c.baseURL.JoinPath("domains") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -47,6 +47,7 @@ func (c Client) ListDomains(ctx context.Context) ([]Domain, error) { } var result []Domain + err = c.do(req, &result) if err != nil { return nil, err @@ -57,7 +58,7 @@ func (c Client) ListDomains(ctx context.Context) ([]Domain, error) { // GetDNSZone Get a DNSZone. // https://api.mittwald.de/v2/docs/#/Domain/dns-get-dns-zone -func (c Client) GetDNSZone(ctx context.Context, zoneID string) (*DNSZone, error) { +func (c *Client) GetDNSZone(ctx context.Context, zoneID string) (*DNSZone, error) { endpoint := c.baseURL.JoinPath("dns-zones", zoneID) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -66,6 +67,7 @@ func (c Client) GetDNSZone(ctx context.Context, zoneID string) (*DNSZone, error) } result := &DNSZone{} + err = c.do(req, result) if err != nil { return nil, err @@ -76,7 +78,7 @@ func (c Client) GetDNSZone(ctx context.Context, zoneID string) (*DNSZone, error) // ListDNSZones List DNSZones belonging to a Project. // https://api.mittwald.de/v2/docs/#/Domain/dns-list-dns-zones -func (c Client) ListDNSZones(ctx context.Context, projectID string) ([]DNSZone, error) { +func (c *Client) ListDNSZones(ctx context.Context, projectID string) ([]DNSZone, error) { endpoint := c.baseURL.JoinPath("projects", projectID, "dns-zones") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -85,6 +87,7 @@ func (c Client) ListDNSZones(ctx context.Context, projectID string) ([]DNSZone, } var result []DNSZone + err = c.do(req, &result) if err != nil { return nil, err @@ -95,7 +98,7 @@ func (c Client) ListDNSZones(ctx context.Context, projectID string) ([]DNSZone, // CreateDNSZone Create a DNSZone. // https://api.mittwald.de/v2/docs/#/Domain/dns-create-dns-zone -func (c Client) CreateDNSZone(ctx context.Context, zone CreateDNSZoneRequest) (*DNSZone, error) { +func (c *Client) CreateDNSZone(ctx context.Context, zone CreateDNSZoneRequest) (*DNSZone, error) { endpoint := c.baseURL.JoinPath("dns-zones") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, zone) @@ -104,6 +107,7 @@ func (c Client) CreateDNSZone(ctx context.Context, zone CreateDNSZoneRequest) (* } result := &DNSZone{} + err = c.do(req, result) if err != nil { return nil, err @@ -114,7 +118,7 @@ func (c Client) CreateDNSZone(ctx context.Context, zone CreateDNSZoneRequest) (* // UpdateTXTRecord Update a record set on a DNSZone. // https://api.mittwald.de/v2/docs/#/Domain/dns-update-record-set -func (c Client) UpdateTXTRecord(ctx context.Context, zoneID string, record TXTRecord) error { +func (c *Client) UpdateTXTRecord(ctx context.Context, zoneID string, record TXTRecord) error { endpoint := c.baseURL.JoinPath("dns-zones", zoneID, "record-sets", "txt") req, err := newJSONRequest(ctx, http.MethodPut, endpoint, record) @@ -127,7 +131,7 @@ func (c Client) UpdateTXTRecord(ctx context.Context, zoneID string, record TXTRe // DeleteDNSZone Delete a DNSZone. // https://api.mittwald.de/v2/docs/#/Domain/dns-delete-dns-zone -func (c Client) DeleteDNSZone(ctx context.Context, zoneID string) error { +func (c *Client) DeleteDNSZone(ctx context.Context, zoneID string) error { endpoint := c.baseURL.JoinPath("dns-zones", zoneID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) @@ -138,7 +142,7 @@ func (c Client) DeleteDNSZone(ctx context.Context, zoneID string) error { return c.do(req, nil) } -func (c Client) do(req *http.Request, result any) error { +func (c *Client) do(req *http.Request, result any) error { req.Header.Set(authorizationHeader, "Bearer "+c.token) resp, err := c.HTTPClient.Do(req) @@ -197,6 +201,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response APIError + err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/mittwald/internal/client_test.go b/providers/dns/mittwald/internal/client_test.go index 63fc52004..e57c80f7a 100644 --- a/providers/dns/mittwald/internal/client_test.go +++ b/providers/dns/mittwald/internal/client_test.go @@ -1,75 +1,36 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, handler) - - client := NewClient("secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client -} - -func testHandler(method string, statusCode int, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "Bearer secret" { - http.Error(rw, fmt.Sprintf("invalid API Token: %s", auth), http.StatusUnauthorized) - return - } - - rw.WriteHeader(statusCode) - - if statusCode == http.StatusNoContent { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - } + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer secret"), + ) } func TestClient_ListDomains(t *testing.T) { - client := setupTest(t, "/domains", testHandler(http.MethodGet, http.StatusOK, "domain-list-domains.json")) + client := mockBuilder(). + Route("GET /domains", servermock.ResponseFromFixture("domain-list-domains.json")). + Build(t) - domains, err := client.ListDomains(context.Background()) + domains, err := client.ListDomains(t.Context()) require.NoError(t, err) require.Len(t, domains, 1) @@ -84,16 +45,22 @@ func TestClient_ListDomains(t *testing.T) { } func TestClient_ListDomains_error(t *testing.T) { - client := setupTest(t, "/domains", testHandler(http.MethodGet, http.StatusBadRequest, "error-client.json")) + client := mockBuilder(). + Route("GET /domains", + servermock.ResponseFromFixture("error-client.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) - _, err := client.ListDomains(context.Background()) + _, err := client.ListDomains(t.Context()) require.EqualError(t, err, "[status code 400] ValidationError: Validation failed [format: should be string (.address.street, email)]") } func TestClient_ListDNSZones(t *testing.T) { - client := setupTest(t, "/projects/my-project-id/dns-zones", testHandler(http.MethodGet, http.StatusOK, "dns-list-dns-zones.json")) + client := mockBuilder(). + Route("GET /projects/my-project-id/dns-zones", servermock.ResponseFromFixture("dns-list-dns-zones.json")). + Build(t) - zones, err := client.ListDNSZones(context.Background(), "my-project-id") + zones, err := client.ListDNSZones(t.Context(), "my-project-id") require.NoError(t, err) require.Len(t, zones, 1) @@ -110,9 +77,11 @@ func TestClient_ListDNSZones(t *testing.T) { } func TestClient_GetDNSZone(t *testing.T) { - client := setupTest(t, "/dns-zones/my-zone-id", testHandler(http.MethodGet, http.StatusOK, "dns-get-dns-zone.json")) + client := mockBuilder(). + Route("GET /dns-zones/my-zone-id", servermock.ResponseFromFixture("dns-get-dns-zone.json")). + Build(t) - zone, err := client.GetDNSZone(context.Background(), "my-zone-id") + zone, err := client.GetDNSZone(t.Context(), "my-zone-id") require.NoError(t, err) expected := &DNSZone{ @@ -127,14 +96,18 @@ func TestClient_GetDNSZone(t *testing.T) { } func TestClient_CreateDNSZone(t *testing.T) { - client := setupTest(t, "/dns-zones", testHandler(http.MethodPost, http.StatusCreated, "dns-create-dns-zone.json")) + client := mockBuilder(). + Route("POST /dns-zones", + servermock.ResponseFromFixture("dns-create-dns-zone.json"), + servermock.CheckRequestJSONBody(`{"name":"test","parentZoneId":"my-parent-zone-id"}`)). + Build(t) request := CreateDNSZoneRequest{ Name: "test", ParentZoneID: "my-parent-zone-id", } - zone, err := client.CreateDNSZone(context.Background(), request) + zone, err := client.CreateDNSZone(t.Context(), request) require.NoError(t, err) expected := &DNSZone{ @@ -145,7 +118,12 @@ func TestClient_CreateDNSZone(t *testing.T) { } func TestClient_UpdateTXTRecord(t *testing.T) { - client := setupTest(t, "/dns-zones/my-zone-id/record-sets/txt", testHandler(http.MethodPut, http.StatusNoContent, "")) + client := mockBuilder(). + Route("PUT /dns-zones/my-zone-id/record-sets/txt", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + servermock.CheckRequestJSONBody(`{"settings":{"ttl":{"auto":true}},"entries":["txt"]}`)). + Build(t) record := TXTRecord{ Settings: Settings{ @@ -154,20 +132,27 @@ func TestClient_UpdateTXTRecord(t *testing.T) { Entries: []string{"txt"}, } - err := client.UpdateTXTRecord(context.Background(), "my-zone-id", record) + err := client.UpdateTXTRecord(t.Context(), "my-zone-id", record) require.NoError(t, err) } func TestClient_DeleteDNSZone(t *testing.T) { - client := setupTest(t, "/dns-zones/my-zone-id", testHandler(http.MethodDelete, http.StatusOK, "")) + client := mockBuilder(). + Route("DELETE /dns-zones/my-zone-id", + servermock.Noop()). + Build(t) - err := client.DeleteDNSZone(context.Background(), "my-zone-id") + err := client.DeleteDNSZone(t.Context(), "my-zone-id") require.NoError(t, err) } func TestClient_DeleteDNSZone_error(t *testing.T) { - client := setupTest(t, "/dns-zones/my-zone-id", testHandler(http.MethodDelete, http.StatusInternalServerError, "error.json")) + client := mockBuilder(). + Route("DELETE /dns-zones/my-zone-id", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusInternalServerError)). + Build(t) - err := client.DeleteDNSZone(context.Background(), "my-zone-id") + err := client.DeleteDNSZone(t.Context(), "my-zone-id") assert.EqualError(t, err, "[status code 500] InternalServerError: Something went wrong") } diff --git a/providers/dns/mittwald/internal/types.go b/providers/dns/mittwald/internal/types.go index df10ab293..86cdf065c 100644 --- a/providers/dns/mittwald/internal/types.go +++ b/providers/dns/mittwald/internal/types.go @@ -1,6 +1,9 @@ package internal -import "fmt" +import ( + "fmt" + "strings" +) // https://api.mittwald.de/v2/docs/#/Domain/domain-list-domains @@ -36,7 +39,7 @@ type NewDNSZone struct { // https://api.mittwald.de/v2/docs/#/Domain/dns-update-record-set type TXTRecord struct { - Settings Settings `json:"settings,omitempty"` + Settings Settings `json:"settings"` Entries []string `json:"entries,omitempty"` } @@ -58,23 +61,25 @@ type APIError struct { } func (a APIError) Error() string { - msg := fmt.Sprintf("%s: %s", a.Type, a.Message) + msg := new(strings.Builder) + + _, _ = fmt.Fprintf(msg, "%s: %s", a.Type, a.Message) if len(a.ValidationErrors) > 0 { for _, validationError := range a.ValidationErrors { - msg += fmt.Sprintf(" [%s: %s (%s, %s)]", + _, _ = fmt.Fprintf(msg, " [%s: %s (%s, %s)]", validationError.Type, validationError.Message, validationError.Path, validationError.Context.Format) } } - return msg + return msg.String() } type ValidationError struct { Message string `json:"message,omitempty"` Path string `json:"path,omitempty"` Type string `json:"type,omitempty"` - Context ValidationErrorContext `json:"context,omitempty"` + Context ValidationErrorContext `json:"context"` } type ValidationErrorContext struct { diff --git a/providers/dns/mittwald/mittwald.go b/providers/dns/mittwald/mittwald.go index 66aafffb3..dcd882482 100644 --- a/providers/dns/mittwald/mittwald.go +++ b/providers/dns/mittwald/mittwald.go @@ -9,10 +9,11 @@ import ( "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/mittwald/internal" - "github.com/miekg/dns" ) // Environment variables names. @@ -30,6 +31,8 @@ const ( const minTTL = 300 +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Token string @@ -90,9 +93,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, fmt.Errorf("mittwald: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } + client := internal.NewClient(config.Token) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, - client: internal.NewClient(config.Token), + client: client, zoneIDs: map[string]string{}, }, nil } @@ -147,6 +158,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.zoneIDsMu.Lock() zoneID, ok := d.zoneIDs[token] d.zoneIDsMu.Unlock() + if !ok { return fmt.Errorf("mittwald: unknown zone ID for '%s'", info.EffectiveFQDN) } @@ -158,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 } @@ -209,11 +225,7 @@ func (d *DNSProvider) getOrCreateZone(ctx context.Context, fqdn string) (*intern } func findDomain(domains []internal.Domain, fqdn string) (internal.Domain, error) { - labelIndexes := dns.Split(fqdn) - - for _, index := range labelIndexes { - domain := dns01.UnFqdn(fqdn[index:]) - + for domain := range dns01.UnFqdnDomainsSeq(fqdn) { for _, dom := range domains { if dom.Domain == domain { return dom, nil @@ -225,11 +237,7 @@ func findDomain(domains []internal.Domain, fqdn string) (internal.Domain, error) } func findZone(zones []internal.DNSZone, fqdn string) (internal.DNSZone, error) { - labelIndexes := dns.Split(fqdn) - - for _, index := range labelIndexes { - domain := dns01.UnFqdn(fqdn[index:]) - + for domain := range dns01.UnFqdnDomainsSeq(fqdn) { for _, zon := range zones { if zon.Domain == domain { return zon, nil diff --git a/providers/dns/mittwald/mittwald.toml b/providers/dns/mittwald/mittwald.toml index 7df9797b6..36a9f6c16 100644 --- a/providers/dns/mittwald/mittwald.toml +++ b/providers/dns/mittwald/mittwald.toml @@ -6,18 +6,18 @@ Since = "v1.48.0" Example = ''' MITTWALD_TOKEN=my-token \ -lego --email you@example.com --dns mittwald -d '*.example.com' -d example.com run +lego --dns mittwald -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] MITTWALD_TOKEN = "API token" [Configuration.Additional] - MITTWALD_POLLING_INTERVAL = "Time between DNS propagation check" - MITTWALD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - MITTWALD_TTL = "The TTL of the TXT record used for the DNS challenge" - MITTWALD_HTTP_TIMEOUT = "API request timeout" - MITTWALD_SEQUENCE_INTERVAL = "Time between sequential requests" + MITTWALD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + MITTWALD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + MITTWALD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + MITTWALD_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 120)" + MITTWALD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.mittwald.de/v2/docs/" diff --git a/providers/dns/mittwald/mittwald_test.go b/providers/dns/mittwald/mittwald_test.go index d8cbdb263..6a6599536 100644 --- a/providers/dns/mittwald/mittwald_test.go +++ b/providers/dns/mittwald/mittwald_test.go @@ -38,6 +38,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -104,6 +105,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -117,6 +119,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/myaddr/internal/client.go b/providers/dns/myaddr/internal/client.go new file mode 100644 index 000000000..40f919c7d --- /dev/null +++ b/providers/dns/myaddr/internal/client.go @@ -0,0 +1,115 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const defaultBaseURL = "https://myaddr.tools" + +// Client the myaddr.{tools,dev,io} API client. +type Client struct { + baseURL *url.URL + HTTPClient *http.Client + + credentials map[string]string + credMu sync.Mutex +} + +// NewClient creates a new Client. +func NewClient(credentials map[string]string) (*Client, error) { + if len(credentials) == 0 { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + credentials: credentials, + }, nil +} + +func (c *Client) AddTXTRecord(ctx context.Context, subdomain, value string) error { + c.credMu.Lock() + privateKey, ok := c.credentials[subdomain] + c.credMu.Unlock() + + if !ok { + return fmt.Errorf("subdomain %s not found in credentials, check your credentials map", subdomain) + } + + payload := ACMEChallenge{Key: privateKey, Data: value} + + req, err := newJSONRequest(ctx, http.MethodPost, c.baseURL.JoinPath("update"), payload) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) do(req *http.Request, result any) error { + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + raw, _ := io.ReadAll(resp.Body) + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} diff --git a/providers/dns/myaddr/internal/client_test.go b/providers/dns/myaddr/internal/client_test.go new file mode 100644 index 000000000..36506d94a --- /dev/null +++ b/providers/dns/myaddr/internal/client_test.go @@ -0,0 +1,62 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + credentials := map[string]string{ + "example": "secret", + } + + client, err := NewClient(credentials) + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) +} + +func TestClient_AddTXTRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /update", nil, + servermock.CheckRequestJSONBody(`{"key":"secret","acme_challenge":"txt"}`)). + Build(t) + + err := client.AddTXTRecord(t.Context(), "example", "txt") + require.NoError(t, err) +} + +func TestClient_AddTXTRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /update", + servermock.ResponseFromFixture("error.txt"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + err := client.AddTXTRecord(t.Context(), "example", "txt") + require.EqualError(t, err, `unexpected status code: [status code: 400] body: invalid value for "key"`) +} + +func TestClient_AddTXTRecord_error_credentials(t *testing.T) { + client := mockBuilder(). + Route("POST /update", nil). + Build(t) + + err := client.AddTXTRecord(t.Context(), "nx", "txt") + require.EqualError(t, err, "subdomain nx not found in credentials, check your credentials map") +} diff --git a/providers/dns/myaddr/internal/fixtures/error.txt b/providers/dns/myaddr/internal/fixtures/error.txt new file mode 100644 index 000000000..64a417673 --- /dev/null +++ b/providers/dns/myaddr/internal/fixtures/error.txt @@ -0,0 +1 @@ +invalid value for "key" diff --git a/providers/dns/myaddr/internal/types.go b/providers/dns/myaddr/internal/types.go new file mode 100644 index 000000000..36f057497 --- /dev/null +++ b/providers/dns/myaddr/internal/types.go @@ -0,0 +1,6 @@ +package internal + +type ACMEChallenge struct { + Key string `json:"key"` + Data string `json:"acme_challenge"` +} diff --git a/providers/dns/myaddr/myaddr.go b/providers/dns/myaddr/myaddr.go new file mode 100644 index 000000000..fb7ea66a0 --- /dev/null +++ b/providers/dns/myaddr/myaddr.go @@ -0,0 +1,147 @@ +// Package myaddr implements a DNS provider for solving the DNS-01 challenge using myaddr.{tools,dev,io}. +package myaddr + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/myaddr/internal" +) + +// Environment variables names. +const ( + envNamespace = "MYADDR_" + + EnvPrivateKeysMapping = envNamespace + "PRIVATE_KEYS_MAPPING" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Credentials map[string]string + + PropagationTimeout time.Duration + PollingInterval time.Duration + SequenceInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for myaddr.{tools,dev,io}. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvPrivateKeysMapping) + if err != nil { + return nil, fmt.Errorf("myaddr: %w", err) + } + + config := NewDefaultConfig() + + credentials, err := env.ParsePairs(values[EnvPrivateKeysMapping]) + if err != nil { + return nil, fmt.Errorf("myaddr: %w", err) + } + + config.Credentials = credentials + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for myaddr.{tools,dev,io}. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("myaddr: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.Credentials) + if err != nil { + return nil, fmt.Errorf("myaddr: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("myaddr: could not find zone for domain %q: %w", domain, err) + } + + fullSubdomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("myaddr: %w", err) + } + + _, after, found := strings.Cut(fullSubdomain, ".") + if !found { + return fmt.Errorf("myaddr: subdomain not found in: %q (%s)", fullSubdomain, info.EffectiveFQDN) + } + + err = d.client.AddTXTRecord(context.Background(), after, info.Value) + if err != nil { + return fmt.Errorf("myaddr: add TXT record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + // There is no API endpoint to delete a TXT record: + // TXT records are automatically removed after a few minutes. + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Sequential All DNS challenges for this provider will be resolved sequentially. +// Returns the interval between each iteration. +func (d *DNSProvider) Sequential() time.Duration { + return d.config.SequenceInterval +} diff --git a/providers/dns/myaddr/myaddr.toml b/providers/dns/myaddr/myaddr.toml new file mode 100644 index 000000000..2f5fe6c1f --- /dev/null +++ b/providers/dns/myaddr/myaddr.toml @@ -0,0 +1,23 @@ +Name = "myaddr.{tools,dev,io}" +Description = '''''' +URL = "https://myaddr.tools/" +Code = "myaddr" +Since = "v4.22.0" + +Example = ''' +MYADDR_PRIVATE_KEYS_MAPPING="example:123,test:456" \ +lego --dns myaddr -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + MYADDR_PRIVATE_KEYS_MAPPING = "Mapping between subdomains and private keys. The format is: `:,:,:`" + [Configuration.Additional] + MYADDR_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + MYADDR_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + MYADDR_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 2)" + MYADDR_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + MYADDR_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://myaddr.tools/" diff --git a/providers/dns/googledomains/googledomains_test.go b/providers/dns/myaddr/myaddr_test.go similarity index 76% rename from providers/dns/googledomains/googledomains_test.go rename to providers/dns/myaddr/myaddr_test.go index 038fb5346..8e555ecfd 100644 --- a/providers/dns/googledomains/googledomains_test.go +++ b/providers/dns/myaddr/myaddr_test.go @@ -1,4 +1,4 @@ -package googledomains +package myaddr import ( "testing" @@ -9,9 +9,7 @@ import ( const envDomain = envNamespace + "DOMAIN" -var envTest = tester.NewEnvTest(EnvAccessToken). - WithDomain(envDomain). - WithLiveTestRequirements(EnvAccessToken, envDomain) +var envTest = tester.NewEnvTest(EnvPrivateKeysMapping).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { @@ -22,29 +20,33 @@ func TestNewDNSProvider(t *testing.T) { { desc: "success", envVars: map[string]string{ - EnvAccessToken: "abc", + EnvPrivateKeysMapping: "example:123", }, - expected: "", }, { desc: "missing credentials", envVars: map[string]string{}, - expected: "googledomains: some credentials information are missing: GOOGLE_DOMAINS_ACCESS_TOKEN", + expected: "myaddr: some credentials information are missing: MYADDR_PRIVATE_KEYS_MAPPING", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() + envTest.Apply(test.envVars) + p, err := NewDNSProvider() + if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) } else { - require.Error(t, err) - require.Contains(t, err.Error(), test.expected) + require.EqualError(t, err, test.expected) } }) } @@ -53,23 +55,23 @@ func TestNewDNSProvider(t *testing.T) { func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string - accessToken string + credentials map[string]string expected string }{ { desc: "success", - accessToken: "abc", + credentials: map[string]string{"example": "123"}, }, { desc: "missing credentials", - expected: "googledomains: access token is missing", + expected: "myaddr: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() - config.AccessToken = test.accessToken + config.Credentials = test.credentials p, err := NewDNSProviderConfig(config) @@ -77,6 +79,7 @@ func TestNewDNSProviderConfig(t *testing.T) { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) + require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } @@ -90,6 +93,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -103,6 +107,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/mydnsjp/internal/client.go b/providers/dns/mydnsjp/internal/client.go index 9859ed685..20469d657 100644 --- a/providers/dns/mydnsjp/internal/client.go +++ b/providers/dns/mydnsjp/internal/client.go @@ -23,7 +23,7 @@ type Client struct { } // NewClient Creates a new Client. -func NewClient(masterID string, password string) *Client { +func NewClient(masterID, password string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ @@ -34,15 +34,15 @@ func NewClient(masterID string, password string) *Client { } } -func (c Client) AddTXTRecord(ctx context.Context, domain, value string) error { +func (c *Client) AddTXTRecord(ctx context.Context, domain, value string) error { return c.doRequest(ctx, domain, value, "REGIST") } -func (c Client) DeleteTXTRecord(ctx context.Context, domain, value string) error { +func (c *Client) DeleteTXTRecord(ctx context.Context, domain, value string) error { return c.doRequest(ctx, domain, value, "DELETE") } -func (c Client) buildRequest(ctx context.Context, domain, value, cmd string) (*http.Request, error) { +func (c *Client) buildRequest(ctx context.Context, domain, value, cmd string) (*http.Request, error) { params := url.Values{} params.Set("CERTBOT_DOMAIN", domain) params.Set("CERTBOT_VALIDATION", value) @@ -58,7 +58,7 @@ func (c Client) buildRequest(ctx context.Context, domain, value, cmd string) (*h return req, nil } -func (c Client) doRequest(ctx context.Context, domain, value, cmd string) error { +func (c *Client) doRequest(ctx context.Context, domain, value, cmd string) error { req, err := c.buildRequest(ctx, domain, value, cmd) if err != nil { return err diff --git a/providers/dns/mydnsjp/internal/client_test.go b/providers/dns/mydnsjp/internal/client_test.go index a68f6888b..41ccbba87 100644 --- a/providers/dns/mydnsjp/internal/client_test.go +++ b/providers/dns/mydnsjp/internal/client_test.go @@ -1,92 +1,50 @@ package internal import ( - "context" - "fmt" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, cmdName string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("xxx", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("invalid method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - username, password, ok := req.BasicAuth() - if !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if username != "xxx" { - http.Error(rw, fmt.Sprintf("username: want %s got %s", username, "xxx"), http.StatusUnauthorized) - return - } - - if password != "secret" { - http.Error(rw, fmt.Sprintf("password: want %s got %s", password, "secret"), http.StatusUnauthorized) - return - } - - if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { - http.Error(rw, fmt.Sprintf("invalid Content-Type: %s", req.Header.Get("Content-Type")), http.StatusBadRequest) - return - } - - err := req.ParseForm() - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - domain := req.Form.Get("CERTBOT_DOMAIN") - if domain != "example.com" { - http.Error(rw, fmt.Sprintf("unexpected CERTBOT_DOMAIN: %s", domain), http.StatusBadRequest) - return - } - - validation := req.Form.Get("CERTBOT_VALIDATION") - if validation != "txt" { - http.Error(rw, fmt.Sprintf("unexpected CERTBOT_VALIDATION: %s", validation), http.StatusBadRequest) - return - } - - cmd := req.Form.Get("EDIT_CMD") - if cmd != cmdName { - http.Error(rw, fmt.Sprintf("unexpected EDIT_CMD: %s", cmd), http.StatusBadRequest) - return - } - }) - - client := NewClient("xxx", "secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(). + WithBasicAuth("xxx", "secret")) } func TestClient_AddTXTRecord(t *testing.T) { - client := setupTest(t, "REGIST") + client := mockBuilder(). + Route("POST /", nil, + servermock.CheckForm().Strict(). + With("CERTBOT_DOMAIN", "example.com"). + With("CERTBOT_VALIDATION", "txt"). + With("EDIT_CMD", "REGIST")). + Build(t) - err := client.AddTXTRecord(context.Background(), "example.com", "txt") + err := client.AddTXTRecord(t.Context(), "example.com", "txt") require.NoError(t, err) } func TestClient_DeleteTXTRecord(t *testing.T) { - client := setupTest(t, "DELETE") + client := mockBuilder(). + Route("POST /", nil, + servermock.CheckForm().Strict(). + With("CERTBOT_DOMAIN", "example.com"). + With("CERTBOT_VALIDATION", "txt"). + With("EDIT_CMD", "DELETE")). + Build(t) - err := client.DeleteTXTRecord(context.Background(), "example.com", "txt") + err := client.DeleteTXTRecord(t.Context(), "example.com", "txt") require.NoError(t, err) } diff --git a/providers/dns/mydnsjp/mydnsjp.go b/providers/dns/mydnsjp/mydnsjp.go index beaaf49ae..8a790c88e 100644 --- a/providers/dns/mydnsjp/mydnsjp.go +++ b/providers/dns/mydnsjp/mydnsjp.go @@ -8,8 +8,10 @@ import ( "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/mydnsjp/internal" ) @@ -25,6 +27,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { MasterID string @@ -38,7 +42,7 @@ type Config struct { func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, @@ -76,9 +80,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("mydnsjp: some credentials information are missing") } + client := internal.NewClient(config.MasterID, config.Password) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, - client: internal.NewClient(config.MasterID, config.Password), + client: client, }, nil } @@ -97,6 +109,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("mydnsjp: %w", err) } + return nil } @@ -109,5 +122,6 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("mydnsjp: %w", err) } + return nil } diff --git a/providers/dns/mydnsjp/mydnsjp.toml b/providers/dns/mydnsjp/mydnsjp.toml index d462e9537..eb9e73acc 100644 --- a/providers/dns/mydnsjp/mydnsjp.toml +++ b/providers/dns/mydnsjp/mydnsjp.toml @@ -7,7 +7,7 @@ Since = "v1.2.0" Example = ''' MYDNSJP_MASTER_ID=xxxxx \ MYDNSJP_PASSWORD=xxxxx \ -lego --email you@example.com --dns mydnsjp -d '*.example.com' -d example.com run +lego --dns mydnsjp -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,10 +15,9 @@ lego --email you@example.com --dns mydnsjp -d '*.example.com' -d example.com run MYDNSJP_MASTER_ID = "Master ID" MYDNSJP_PASSWORD = "Password" [Configuration.Additional] - MYDNSJP_POLLING_INTERVAL = "Time between DNS propagation check" - MYDNSJP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - MYDNSJP_TTL = "The TTL of the TXT record used for the DNS challenge" - MYDNSJP_HTTP_TIMEOUT = "API request timeout" + MYDNSJP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + MYDNSJP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + MYDNSJP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.mydns.jp/?MENU=030" diff --git a/providers/dns/mydnsjp/mydnsjp_test.go b/providers/dns/mydnsjp/mydnsjp_test.go index 96eb95865..c82bd2264 100644 --- a/providers/dns/mydnsjp/mydnsjp_test.go +++ b/providers/dns/mydnsjp/mydnsjp_test.go @@ -56,6 +56,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -124,6 +125,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -137,6 +139,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/mythicbeasts/internal/client.go b/providers/dns/mythicbeasts/internal/client.go index 91fbbaf54..82c51dbf3 100644 --- a/providers/dns/mythicbeasts/internal/client.go +++ b/providers/dns/mythicbeasts/internal/client.go @@ -35,7 +35,7 @@ type Client struct { } // NewClient Creates a new Client. -func NewClient(username string, password string) *Client { +func NewClient(username, password string) *Client { apiEndpoint, _ := url.Parse(APIBaseURL) authEndpoint, _ := url.Parse(AuthBaseURL) @@ -99,6 +99,7 @@ func (c *Client) createTXTRecord(ctx context.Context, zone, leaf, recordType, va } resp := &createTXTResponse{} + err = c.do(req, resp) if err != nil { return nil, err diff --git a/providers/dns/mythicbeasts/internal/client_test.go b/providers/dns/mythicbeasts/internal/client_test.go index 7e3857986..acbf85268 100644 --- a/providers/dns/mythicbeasts/internal/client_test.go +++ b/providers/dns/mythicbeasts/internal/client_test.go @@ -1,69 +1,54 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" "time" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.APIEndpoint, _ = url.Parse(server.URL) + client.token = &Token{ + Token: "secret", + Lifetime: 60, + TokenType: "bearer", + Deadline: time.Now().Add(1 * time.Minute), + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, handler) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.APIEndpoint, _ = url.Parse(server.URL) - client.token = &Token{ - Token: "secret", - Lifetime: 60, - TokenType: "bearer", - Deadline: time.Now().Add(1 * time.Minute), - } - - return client -} - -func writeFixtureHandler(method, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) - } + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer "+fakeToken), + ) } func TestClient_CreateTXTRecord(t *testing.T) { - client := setupTest(t, "/zones/example.com/records/foo/TXT", writeFixtureHandler(http.MethodPost, "post-zoneszonerecords.json")) + client := mockBuilder(). + Route("POST /zones/example.com/records/foo/TXT", + servermock.ResponseFromFixture("post-zoneszonerecords.json"), + servermock.CheckRequestJSONBody(`{"records":[{"host":"foo","ttl":120,"type":"TXT","data":"txt"}]}`)). + Build(t) - err := client.CreateTXTRecord(mockContext(), "example.com", "foo", "txt", 120) + err := client.CreateTXTRecord(mockContext(t), "example.com", "foo", "txt", 120) require.NoError(t, err) } func TestClient_RemoveTXTRecord(t *testing.T) { - client := setupTest(t, "/zones/example.com/records/foo/TXT", writeFixtureHandler(http.MethodDelete, "delete-zoneszonerecords.json")) + client := mockBuilder(). + Route("DELETE /zones/example.com/records/foo/TXT", + servermock.ResponseFromFixture("delete-zoneszonerecords.json"), + servermock.CheckQueryParameter().Strict(). + With("data", "txt")). + Build(t) - err := client.RemoveTXTRecord(mockContext(), "example.com", "foo", "txt") + err := client.RemoveTXTRecord(mockContext(t), "example.com", "foo", "txt") require.NoError(t, err) } diff --git a/providers/dns/mythicbeasts/internal/fixtures/token.json b/providers/dns/mythicbeasts/internal/fixtures/token.json new file mode 100644 index 000000000..f23fe58ea --- /dev/null +++ b/providers/dns/mythicbeasts/internal/fixtures/token.json @@ -0,0 +1,5 @@ +{ + "access_token": "xxx", + "expires_in": 666, + "token_type": "bearer" +} diff --git a/providers/dns/mythicbeasts/internal/identity.go b/providers/dns/mythicbeasts/internal/identity.go index 417f1c759..15e35ba69 100644 --- a/providers/dns/mythicbeasts/internal/identity.go +++ b/providers/dns/mythicbeasts/internal/identity.go @@ -44,6 +44,7 @@ func (c *Client) obtainToken(ctx context.Context) (*Token, error) { } tok := Token{} + err = json.Unmarshal(raw, &tok) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) @@ -83,6 +84,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errResp := &authResponseError{} + err := json.Unmarshal(raw, errResp) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/mythicbeasts/internal/identity_test.go b/providers/dns/mythicbeasts/internal/identity_test.go index 9d8daf827..3e1e8ba4f 100644 --- a/providers/dns/mythicbeasts/internal/identity_test.go +++ b/providers/dns/mythicbeasts/internal/identity_test.go @@ -2,80 +2,72 @@ package internal import ( "context" - "encoding/json" - "fmt" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func mockContext() context.Context { - return context.WithValue(context.Background(), tokenKey, &Token{Token: "xxx"}) +const fakeToken = "xxx" + +func mockContext(t *testing.T) context.Context { + t.Helper() + + return context.WithValue(t.Context(), tokenKey, &Token{Token: fakeToken}) } -func tokenHandler(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("invalid method, got %s want %s", req.Method, http.MethodPost), http.StatusMethodNotAllowed) - return - } +func mockBuilderIdentity() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.AuthEndpoint, _ = url.Parse(server.URL) - username, password, ok := req.BasicAuth() - if !ok || username != "user" || password != "secret" { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - _ = json.NewEncoder(rw).Encode(Token{ - Token: "xxx", - Lifetime: 666, - TokenType: "bearer", - }) + return client, nil + }, + servermock.CheckHeader(). + WithBasicAuth("user", "secret"), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()) } func TestClient_obtainToken(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", tokenHandler) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.AuthEndpoint, _ = url.Parse(server.URL) + client := mockBuilderIdentity(). + Route("POST /", + servermock.ResponseFromFixture("token.json"), + servermock.CheckForm().Strict(). + With("grant_type", "client_credentials")). + Build(t) assert.Nil(t, client.token) - tok, err := client.obtainToken(context.Background()) + tok, err := client.obtainToken(t.Context()) require.NoError(t, err) assert.NotNil(t, tok) assert.NotZero(t, tok.Deadline) - assert.Equal(t, "xxx", tok.Token) + assert.Equal(t, fakeToken, tok.Token) } func TestClient_CreateAuthenticatedContext(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", tokenHandler) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.AuthEndpoint, _ = url.Parse(server.URL) + client := mockBuilderIdentity(). + Route("POST /", + servermock.ResponseFromFixture("token.json"), + servermock.CheckForm().Strict(). + With("grant_type", "client_credentials")). + Build(t) assert.Nil(t, client.token) - ctx, err := client.CreateAuthenticatedContext(context.Background()) + ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) tok := getToken(ctx) assert.NotNil(t, tok) assert.NotZero(t, tok.Deadline) - assert.Equal(t, "xxx", tok.Token) + assert.Equal(t, fakeToken, tok.Token) } diff --git a/providers/dns/mythicbeasts/mythicbeasts.go b/providers/dns/mythicbeasts/mythicbeasts.go index a23ff5701..e8f5081f7 100644 --- a/providers/dns/mythicbeasts/mythicbeasts.go +++ b/providers/dns/mythicbeasts/mythicbeasts.go @@ -9,8 +9,10 @@ import ( "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/mythicbeasts/internal" ) @@ -29,6 +31,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { UserName string @@ -84,6 +88,7 @@ func NewDNSProvider() (*DNSProvider, error) { if err != nil { return nil, fmt.Errorf("mythicbeasts: %w", err) } + config.UserName = values[EnvUserName] config.Password = values[EnvPassword] @@ -114,6 +119,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } diff --git a/providers/dns/mythicbeasts/mythicbeasts.toml b/providers/dns/mythicbeasts/mythicbeasts.toml index 86d69d017..cada3041d 100644 --- a/providers/dns/mythicbeasts/mythicbeasts.toml +++ b/providers/dns/mythicbeasts/mythicbeasts.toml @@ -7,7 +7,7 @@ Since = "v0.3.7" Example = ''' MYTHICBEASTS_USERNAME=myuser \ MYTHICBEASTS_PASSWORD=mypass \ -lego --email you@example.com --dns mythicbeasts -d '*.example.com' -d example.com run +lego --dns mythicbeasts -d '*.example.com' -d example.com run ''' Additional = ''' @@ -23,10 +23,10 @@ Your API key name is not needed to operate lego. [Configuration.Additional] MYTHICBEASTS_API_ENDPOINT = "The endpoint for the API (must implement v2)" MYTHICBEASTS_AUTH_API_ENDPOINT = "The endpoint for Mythic Beasts' Authentication" - MYTHICBEASTS_POLLING_INTERVAL = "Time between DNS propagation check" - MYTHICBEASTS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - MYTHICBEASTS_TTL = "The TTL of the TXT record used for the DNS challenge" - MYTHICBEASTS_HTTP_TIMEOUT = "API request timeout" + MYTHICBEASTS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + MYTHICBEASTS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + MYTHICBEASTS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + MYTHICBEASTS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://www.mythic-beasts.com/support/api/dnsv2" diff --git a/providers/dns/mythicbeasts/mythicbeasts_test.go b/providers/dns/mythicbeasts/mythicbeasts_test.go index 5a8a9d4bb..c684725b7 100644 --- a/providers/dns/mythicbeasts/mythicbeasts_test.go +++ b/providers/dns/mythicbeasts/mythicbeasts_test.go @@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -108,6 +109,7 @@ func TestNewDNSProviderConfig(t *testing.T) { t.Run(test.desc, func(t *testing.T) { config, err := NewDefaultConfig() require.NoError(t, err) + config.UserName = test.username config.Password = test.password @@ -130,6 +132,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -143,6 +146,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/namecheap/internal/client.go b/providers/dns/namecheap/internal/client.go index f7ca8f66f..6fb737b95 100644 --- a/providers/dns/namecheap/internal/client.go +++ b/providers/dns/namecheap/internal/client.go @@ -32,7 +32,7 @@ type Client struct { } // NewClient creates a new Client. -func NewClient(apiUser string, apiKey string, clientIP string) *Client { +func NewClient(apiUser, apiKey, clientIP string) *Client { return &Client{ apiUser: apiUser, apiKey: apiKey, @@ -54,6 +54,7 @@ func (c *Client) GetHosts(ctx context.Context, sld, tld string) ([]Record, error } var ghr getHostsResponse + err = c.do(request, &ghr) if err != nil { return nil, err @@ -88,6 +89,7 @@ func (c *Client) SetHosts(ctx context.Context, sld, tld string, hosts []Record) } var shr setHostsResponse + err = c.do(req, &shr) if err != nil { return err @@ -96,6 +98,7 @@ func (c *Client) SetHosts(ctx context.Context, sld, tld string, hosts []Record) if len(shr.Errors) > 0 { return shr.Errors[0] } + if shr.Result.IsSuccess != "true" { return errors.New("setHosts failed") } diff --git a/providers/dns/namecheap/internal/client_test.go b/providers/dns/namecheap/internal/client_test.go index 9d78ee213..d7bea7b6e 100644 --- a/providers/dns/namecheap/internal/client_test.go +++ b/providers/dns/namecheap/internal/client_test.go @@ -1,75 +1,38 @@ package internal import ( - "context" - "fmt" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, handler http.HandlerFunc) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", handler) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret", "127.0.0.1") client.HTTPClient = server.Client() client.BaseURL = server.URL - return client -} - -func writeFixture(rw http.ResponseWriter, filename string) { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) + return client, nil } func TestClient_GetHosts(t *testing.T) { - client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", + servermock.ResponseFromFixture("getHosts.xml"), + servermock.CheckQueryParameter().Strict(). + With("ApiKey", "secret"). + With("ApiUser", "user"). + With("ClientIp", "127.0.0.1"). + With("Command", "namecheap.domains.dns.getHosts"). + With("SLD", "foo"). + With("TLD", "example.com"). + With("UserName", "user"), + ). + Build(t) - expectedParams := map[string]string{ - "ApiKey": "secret", - "ApiUser": "user", - "ClientIp": "127.0.0.1", - "Command": "namecheap.domains.dns.getHosts", - "SLD": "foo", - "TLD": "example.com", - "UserName": "user", - } - - query := req.URL.Query() - for k, v := range expectedParams { - if query.Get(k) != v { - http.Error(rw, fmt.Sprintf("invalid query parameter %s value: %s", k, query.Get(k)), http.StatusBadRequest) - return - } - } - - writeFixture(rw, "getHosts.xml") - }) - - hosts, err := client.GetHosts(context.Background(), "foo", "example.com") + hosts, err := client.GetHosts(t.Context(), "foo", "example.com") require.NoError(t, err) expected := []Record{ @@ -81,93 +44,62 @@ func TestClient_GetHosts(t *testing.T) { } func TestClient_GetHosts_error(t *testing.T) { - client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", + servermock.ResponseFromFixture("getHosts_errorBadAPIKey1.xml")). + Build(t) - writeFixture(rw, "getHosts_errorBadAPIKey1.xml") - }) - - _, err := client.GetHosts(context.Background(), "foo", "example.com") + _, err := client.GetHosts(t.Context(), "foo", "example.com") require.ErrorAs(t, err, &apiError{}) } func TestClient_SetHosts(t *testing.T) { - client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { - http.Error(rw, fmt.Sprintf("invalid Content-Type: %s", req.Header.Get("Content-Type")), http.StatusBadRequest) - return - } - - err := req.ParseForm() - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - expectedParams := map[string]string{ - "HostName1": "_acme-challenge.test.example.com", - "RecordType1": "TXT", - "Address1": "txtTXTtxt", - "MXPref1": "10", - "TTL1": "120", - - "HostName2": "_acme-challenge.test.example.org", - "RecordType2": "TXT", - "Address2": "txtTXTtxt", - "MXPref2": "10", - "TTL2": "120", - - "ApiKey": "secret", - "ApiUser": "user", - "ClientIp": "127.0.0.1", - "Command": "namecheap.domains.dns.setHosts", - "SLD": "foo", - "TLD": "example.com", - "UserName": "user", - } - - for k, v := range expectedParams { - if req.Form.Get(k) != v { - http.Error(rw, fmt.Sprintf("invalid form data %s value: %q", k, req.Form.Get(k)), http.StatusBadRequest) - return - } - } - - writeFixture(rw, "setHosts.xml") - }) + client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithContentTypeFromURLEncoded()). + Route("POST /", + servermock.ResponseFromFixture("setHosts.xml"), + servermock.CheckForm().Strict(). + With("ApiKey", "secret"). + With("ApiUser", "user"). + With("ClientIp", "127.0.0.1"). + With("Command", "namecheap.domains.dns.setHosts"). + With("SLD", "foo"). + With("TLD", "example.com"). + With("UserName", "user"). + // entry 1 + With("HostName1", "_acme-challenge.test.example.com"). + With("RecordType1", "TXT"). + With("Address1", "txtTXTtxt"). + With("MXPref1", "10"). + With("TTL1", "120"). + // entry 2 + With("HostName2", "_acme-challenge.test.example.org"). + With("RecordType2", "TXT"). + With("Address2", "txtTXTtxt"). + With("MXPref2", "10"). + With("TTL2", "120"), + ). + Build(t) records := []Record{ {Name: "_acme-challenge.test.example.com", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"}, {Name: "_acme-challenge.test.example.org", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"}, } - err := client.SetHosts(context.Background(), "foo", "example.com", records) + err := client.SetHosts(t.Context(), "foo", "example.com", records) require.NoError(t, err) } func TestClient_SetHosts_error(t *testing.T) { - client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - writeFixture(rw, "setHosts_errorBadAPIKey1.xml") - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /", + servermock.ResponseFromFixture("setHosts_errorBadAPIKey1.xml")). + Build(t) records := []Record{ {Name: "_acme-challenge.test.example.com", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"}, {Name: "_acme-challenge.test.example.org", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"}, } - err := client.SetHosts(context.Background(), "foo", "example.com", records) + err := client.SetHosts(t.Context(), "foo", "example.com", records) require.ErrorAs(t, err, &apiError{}) } diff --git a/providers/dns/namecheap/namecheap.go b/providers/dns/namecheap/namecheap.go index c4d9c0699..54640f8e0 100644 --- a/providers/dns/namecheap/namecheap.go +++ b/providers/dns/namecheap/namecheap.go @@ -10,9 +10,11 @@ import ( "strings" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/namecheap/internal" "golang.org/x/net/publicsuffix" ) @@ -45,47 +47,7 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) -// A challenge represents all the data needed to specify a dns-01 challenge to lets-encrypt. -type challenge struct { - domain string - key string - keyFqdn string - keyValue string - tld string - sld string - host string -} - -// newChallenge builds a challenge record from a domain name and a challenge authentication key. -func newChallenge(domain, keyAuth string) (*challenge, error) { - domain = dns01.UnFqdn(domain) - - tld, _ := publicsuffix.PublicSuffix(domain) - if tld == domain { - return nil, fmt.Errorf("invalid domain name %q", domain) - } - - parts := strings.Split(domain, ".") - longest := len(parts) - strings.Count(tld, ".") - 1 - sld := parts[longest-1] - - var host string - if longest >= 1 { - host = strings.Join(parts[:longest-1], ".") - } - - info := dns01.GetChallengeInfo(domain, keyAuth) - - return &challenge{ - domain: domain, - key: "_acme-challenge." + host, - keyFqdn: info.EffectiveFQDN, - keyValue: info.Value, - tld: tld, - sld: sld, - host: host, - }, nil -} +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { @@ -111,10 +73,11 @@ func NewDefaultConfig() *Config { BaseURL: baseURL, Debug: env.GetOrDefaultBool(EnvDebug, false), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Minute), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, time.Hour), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 15*time.Second), HTTPClient: &http.Client{ - Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 60*time.Second), + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, time.Minute), + Transport: defaultTransport(envNamespace), }, } } @@ -156,6 +119,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if err != nil { return nil, fmt.Errorf("namecheap: %w", err) } + config.ClientIP = clientIP } @@ -166,6 +130,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } @@ -178,22 +144,22 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { // Present installs a TXT record for the DNS challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { // TODO(ldez) replace domain by FQDN to follow CNAME. - ch, err := newChallenge(domain, keyAuth) + pr, err := newPseudoRecord(domain, keyAuth) if err != nil { return fmt.Errorf("namecheap: %w", err) } ctx := context.Background() - records, err := d.client.GetHosts(ctx, ch.sld, ch.tld) + records, err := d.client.GetHosts(ctx, pr.sld, pr.tld) if err != nil { return fmt.Errorf("namecheap: %w", err) } record := internal.Record{ - Name: ch.key, + Name: pr.key, Type: "TXT", - Address: ch.keyValue, + Address: pr.keyValue, MXPref: "10", TTL: strconv.Itoa(d.config.TTL), } @@ -206,33 +172,37 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { } } - err = d.client.SetHosts(ctx, ch.sld, ch.tld, records) + err = d.client.SetHosts(ctx, pr.sld, pr.tld, records) if err != nil { return fmt.Errorf("namecheap: %w", err) } + return nil } // CleanUp removes a TXT record used for a previous DNS challenge. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { // TODO(ldez) replace domain by FQDN to follow CNAME. - ch, err := newChallenge(domain, keyAuth) + pr, err := newPseudoRecord(domain, keyAuth) if err != nil { return fmt.Errorf("namecheap: %w", err) } ctx := context.Background() - records, err := d.client.GetHosts(ctx, ch.sld, ch.tld) + records, err := d.client.GetHosts(ctx, pr.sld, pr.tld) if err != nil { return fmt.Errorf("namecheap: %w", err) } // Find the challenge TXT record and remove it if found. - var found bool - var newRecords []internal.Record + var ( + found bool + newRecords []internal.Record + ) + for _, h := range records { - if h.Name == ch.key && h.Type == "TXT" { + if h.Name == pr.key && h.Type == "TXT" { found = true } else { newRecords = append(newRecords, h) @@ -243,9 +213,52 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } - err = d.client.SetHosts(ctx, ch.sld, ch.tld, newRecords) + err = d.client.SetHosts(ctx, pr.sld, pr.tld, newRecords) if err != nil { return fmt.Errorf("namecheap: %w", err) } + return nil } + +// A pseudoRecord represents all the data needed to specify a dns-01 challenge to lets-encrypt. +type pseudoRecord struct { + domain string + key string + keyFqdn string + keyValue string + tld string + sld string + host string +} + +// newPseudoRecord builds a challenge record from a domain name and a challenge authentication key. +func newPseudoRecord(domain, keyAuth string) (*pseudoRecord, error) { + domain = dns01.UnFqdn(domain) + + tld, _ := publicsuffix.PublicSuffix(domain) + if tld == domain { + return nil, fmt.Errorf("invalid domain name %q", domain) + } + + parts := strings.Split(domain, ".") + longest := len(parts) - strings.Count(tld, ".") - 1 + sld := parts[longest-1] + + var host string + if longest >= 1 { + host = strings.Join(parts[:longest-1], ".") + } + + info := dns01.GetChallengeInfo(domain, keyAuth) + + return &pseudoRecord{ + domain: domain, + key: "_acme-challenge." + host, + keyFqdn: info.EffectiveFQDN, + keyValue: info.Value, + tld: tld, + sld: sld, + host: host, + }, nil +} diff --git a/providers/dns/namecheap/namecheap.toml b/providers/dns/namecheap/namecheap.toml index ef2ef53c4..b0f92a1bd 100644 --- a/providers/dns/namecheap/namecheap.toml +++ b/providers/dns/namecheap/namecheap.toml @@ -14,7 +14,7 @@ More information in the section [Enabling API Access](https://www.namecheap.com/ Example = ''' NAMECHEAP_API_USER=user \ NAMECHEAP_API_KEY=key \ -lego --email you@example.com --dns namecheap -d '*.example.com' -d example.com run +lego --dns namecheap -d '*.example.com' -d example.com run ''' [Configuration] @@ -22,10 +22,10 @@ lego --email you@example.com --dns namecheap -d '*.example.com' -d example.com r NAMECHEAP_API_USER = "API user" NAMECHEAP_API_KEY = "API key" [Configuration.Additional] - NAMECHEAP_POLLING_INTERVAL = "Time between DNS propagation check" - NAMECHEAP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - NAMECHEAP_TTL = "The TTL of the TXT record used for the DNS challenge" - NAMECHEAP_HTTP_TIMEOUT = "API request timeout" + NAMECHEAP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 15)" + NAMECHEAP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 3600)" + NAMECHEAP_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + NAMECHEAP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" NAMECHEAP_SANDBOX = "Activate the sandbox (boolean)" [Links] diff --git a/providers/dns/namecheap/namecheap_test.go b/providers/dns/namecheap/namecheap_test.go index 4f4036ded..e55a4a6bc 100644 --- a/providers/dns/namecheap/namecheap_test.go +++ b/providers/dns/namecheap/namecheap_test.go @@ -1,16 +1,10 @@ package namecheap import ( - "io" - "net/http" "net/http/httptest" - "net/url" - "os" - "path/filepath" "testing" - "time" - "github.com/go-acme/lego/v4/providers/dns/namecheap/internal" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,7 +18,6 @@ const ( type testCase struct { name string domain string - hosts []internal.Record errString string getHostsResponse string setHostsResponse string @@ -32,26 +25,14 @@ type testCase struct { var testCases = []testCase{ { - name: "Test:Success:1", - domain: "test.example.com", - hosts: []internal.Record{ - {Type: "A", Name: "home", Address: "10.0.0.1", MXPref: "10", TTL: "1799"}, - {Type: "A", Name: "www", Address: "10.0.0.2", MXPref: "10", TTL: "1200"}, - {Type: "AAAA", Name: "a", Address: "::0", MXPref: "10", TTL: "1799"}, - {Type: "CNAME", Name: "*", Address: "example.com.", MXPref: "10", TTL: "1799"}, - {Type: "MXE", Name: "example.com", Address: "10.0.0.5", MXPref: "10", TTL: "1800"}, - {Type: "URL", Name: "xyz", Address: "https://google.com", MXPref: "10", TTL: "1799"}, - }, + name: "Test:Success:1", + domain: "test.example.com", getHostsResponse: "getHosts_success1.xml", setHostsResponse: "setHosts_success1.xml", }, { - name: "Test:Success:2", - domain: "example.com", - hosts: []internal.Record{ - {Type: "A", Name: "@", Address: "10.0.0.2", MXPref: "10", TTL: "1200"}, - {Type: "A", Name: "www", Address: "10.0.0.3", MXPref: "10", TTL: "60"}, - }, + name: "Test:Success:2", + domain: "example.com", getHostsResponse: "getHosts_success2.xml", setHostsResponse: "setHosts_success2.xml", }, @@ -63,96 +44,37 @@ var testCases = []testCase{ }, } -func setupTest(t *testing.T, tc *testCase) *DNSProvider { - t.Helper() - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - values := r.URL.Query() - cmd := values.Get("Command") - switch cmd { - case "namecheap.domains.dns.getHosts": - assertHdr(t, tc, &values) - w.WriteHeader(http.StatusOK) - writeFixture(w, tc.getHostsResponse) - default: - t.Errorf("Unexpected GET command: %s", cmd) - } - - case http.MethodPost: - err := r.ParseForm() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - values := r.Form - cmd := values.Get("Command") - switch cmd { - case "namecheap.domains.dns.setHosts": - assertHdr(t, tc, &values) - w.WriteHeader(http.StatusOK) - writeFixture(w, tc.setHostsResponse) - default: - t.Errorf("Unexpected POST command: %s", cmd) - } - - default: - t.Errorf("Unexpected http method: %s", r.Method) - } - }) - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - return mockDNSProvider(t, server.URL) -} - -func mockDNSProvider(t *testing.T, baseURL string) *DNSProvider { - t.Helper() - - config := NewDefaultConfig() - config.BaseURL = baseURL - config.APIUser = envTestUser - config.APIKey = envTestKey - config.ClientIP = envTestClientIP - config.HTTPClient = &http.Client{Timeout: 60 * time.Second} - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - return provider -} - -func assertHdr(t *testing.T, tc *testCase, values *url.Values) { - t.Helper() - - ch, _ := newChallenge(tc.domain, "") - assert.Equal(t, envTestUser, values.Get("ApiUser"), "ApiUser") - assert.Equal(t, envTestKey, values.Get("ApiKey"), "ApiKey") - assert.Equal(t, envTestUser, values.Get("UserName"), "UserName") - assert.Equal(t, envTestClientIP, values.Get("ClientIp"), "ClientIp") - assert.Equal(t, ch.sld, values.Get("SLD"), "SLD") - assert.Equal(t, ch.tld, values.Get("TLD"), "TLD") -} - -func writeFixture(rw http.ResponseWriter, filename string) { - file, err := os.Open(filepath.Join("internal", "fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) -} - func TestDNSProvider_Present(t *testing.T) { for _, test := range testCases { t.Run(test.name, func(t *testing.T) { - p := setupTest(t, &test) + ch, _ := newPseudoRecord(test.domain, "") - err := p.Present(test.domain, "", "dummyKey") + provider := mockBuilder(). + Route("GET /", + servermock.ResponseFromInternal(test.getHostsResponse), + servermock.CheckForm().Strict(). + With("ClientIp", "10.0.0.1"). + With("Command", "namecheap.domains.dns.getHosts"). + With("SLD", ch.sld). + With("TLD", ch.tld). + With("UserName", "foo"). + With("ApiKey", "bar"). + With("ApiUser", "foo"), + ). + Route("POST /", + servermock.ResponseFromInternal(test.setHostsResponse), + servermock.CheckForm(). + With("ClientIp", "10.0.0.1"). + With("Command", "namecheap.domains.dns.setHosts"). + With("SLD", ch.sld). + With("TLD", ch.tld). + With("UserName", "foo"). + With("ApiKey", "bar"). + With("ApiUser", "foo"), + ). + Build(t) + + err := provider.Present(test.domain, "", "dummyKey") if test.errString != "" { assert.EqualError(t, err, "namecheap: "+test.errString) } else { @@ -165,9 +87,34 @@ func TestDNSProvider_Present(t *testing.T) { func TestDNSProvider_CleanUp(t *testing.T) { for _, test := range testCases { t.Run(test.name, func(t *testing.T) { - p := setupTest(t, &test) + ch, _ := newPseudoRecord(test.domain, "") - err := p.CleanUp(test.domain, "", "dummyKey") + provider := mockBuilder(). + Route("GET /", + servermock.ResponseFromInternal(test.getHostsResponse), + servermock.CheckForm().Strict(). + With("ClientIp", "10.0.0.1"). + With("Command", "namecheap.domains.dns.getHosts"). + With("SLD", ch.sld). + With("TLD", ch.tld). + With("UserName", "foo"). + With("ApiKey", "bar"). + With("ApiUser", "foo"), + ). + Route("POST /", + servermock.ResponseFromInternal(test.setHostsResponse), + servermock.CheckForm(). + With("ClientIp", "10.0.0.1"). + With("Command", "namecheap.domains.dns.setHosts"). + With("SLD", ch.sld). + With("TLD", ch.tld). + With("UserName", "foo"). + With("ApiKey", "bar"). + With("ApiUser", "foo"), + ). + Build(t) + + err := provider.CleanUp(test.domain, "", "dummyKey") if test.errString != "" { assert.EqualError(t, err, "namecheap: "+test.errString) } else { @@ -177,7 +124,7 @@ func TestDNSProvider_CleanUp(t *testing.T) { } } -func TestDomainSplit(t *testing.T) { +func Test_newPseudoRecord_domainSplit(t *testing.T) { tests := []struct { domain string valid bool @@ -205,7 +152,8 @@ func TestDomainSplit(t *testing.T) { for _, test := range tests { t.Run(test.domain, func(t *testing.T) { valid := true - ch, err := newChallenge(test.domain, "") + + ch, err := newPseudoRecord(test.domain, "") if err != nil { valid = false } @@ -226,3 +174,16 @@ func TestDomainSplit(t *testing.T) { }) } } + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.HTTPClient = server.Client() + config.BaseURL = server.URL + config.APIUser = envTestUser + config.APIKey = envTestKey + config.ClientIP = envTestClientIP + + return NewDNSProviderConfig(config) + }) +} diff --git a/providers/dns/namecheap/transport.go b/providers/dns/namecheap/transport.go new file mode 100644 index 000000000..584dc6e50 --- /dev/null +++ b/providers/dns/namecheap/transport.go @@ -0,0 +1,71 @@ +package namecheap + +import ( + "net/http" + "net/url" + "strings" + "sync" + + "github.com/go-acme/lego/v4/platform/config/env" + "golang.org/x/net/http/httpproxy" +) + +const ( + envHTTPProxy = "HTTP_PROXY" + envHTTPProxyLower = "http_proxy" + envHTTPSProxy = "HTTPS_PROXY" + envHTTPSProxyLower = "https_proxy" + envNoProxy = "NO_PROXY" + envNoProxyLower = "no_proxy" + envRequestMethod = "REQUEST_METHOD" +) + +// Allows lazy loading of the proxy. +var ( + envProxyOnce sync.Once + envProxyFuncValue func(*url.URL) (*url.URL, error) +) + +func defaultTransport(namespace string) http.RoundTripper { + tr, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return nil + } + + clone := tr.Clone() + clone.Proxy = proxyFromEnvironment(namespace) + + return clone +} + +// Inspired by: +// - https://pkg.go.dev/net/http#ProxyFromEnvironment +// - https://pkg.go.dev/golang.org/x/net/http/httpproxy#FromEnvironment +func envProxyFunc(namespace string) func(*url.URL) (*url.URL, error) { + envProxyOnce.Do(func() { + cfg := &httpproxy.Config{ + HTTPProxy: getEnv(namespace, envHTTPProxy, envHTTPProxyLower), + HTTPSProxy: getEnv(namespace, envHTTPSProxy, envHTTPSProxyLower), + NoProxy: getEnv(namespace, envNoProxy, envNoProxyLower), + CGI: env.GetOneWithFallback(namespace+envRequestMethod, "", env.ParseString, envRequestMethod) != "", + } + + envProxyFuncValue = cfg.ProxyFunc() + }) + + return envProxyFuncValue +} + +// Inspired by: +// - https://pkg.go.dev/net/http#ProxyFromEnvironment +// - https://pkg.go.dev/golang.org/x/net/http/httpproxy#FromEnvironment +func proxyFromEnvironment(namespace string) func(req *http.Request) (*url.URL, error) { + return func(req *http.Request) (*url.URL, error) { + return envProxyFunc(namespace)(req.URL) + } +} + +func getEnv(namespace, baseEnvName, baseEnvNameLower string) string { + return env.GetOneWithFallback(namespace+baseEnvName, "", env.ParseString, + strings.ToLower(namespace)+baseEnvNameLower, baseEnvName, baseEnvNameLower) +} diff --git a/providers/dns/namecheap/transport_test.go b/providers/dns/namecheap/transport_test.go new file mode 100644 index 000000000..cd3e9ff17 --- /dev/null +++ b/providers/dns/namecheap/transport_test.go @@ -0,0 +1,39 @@ +package namecheap + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_defaultTransport(t *testing.T) { + client := servermock.NewBuilder( + func(server *httptest.Server) (*http.Client, error) { + cl := server.Client() + + t.Setenv("NAMECHEAP_HTTP_PROXY", server.URL) + + cl.Transport = defaultTransport(envNamespace) + + return cl, nil + }). + Route("/", + servermock.Noop().WithStatusCode(http.StatusTeapot)). + Build(t) + + req, err := http.NewRequest(http.MethodGet, "http://example.com", nil) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + + t.Cleanup(func() { + _ = resp.Body.Close() + }) + + assert.Equal(t, http.StatusTeapot, resp.StatusCode) +} diff --git a/providers/dns/namedotcom/namedotcom.go b/providers/dns/namedotcom/namedotcom.go index e49a15a9a..04c8b5967 100644 --- a/providers/dns/namedotcom/namedotcom.go +++ b/providers/dns/namedotcom/namedotcom.go @@ -7,14 +7,13 @@ import ( "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/namedotcom/go/namecom" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/namedotcom/go/v4/namecom" ) -// according to https://www.name.com/api-docs/DNS#CreateRecord -const minTTL = 300 - // Environment variables names. const ( envNamespace = "NAMECOM_" @@ -29,6 +28,11 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +// according to https://www.name.com/api-docs/DNS#CreateRecord +const minTTL = 300 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Username string @@ -94,7 +98,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { } client := namecom.New(config.Username, config.APIToken) - client.Client = config.HTTPClient + + if config.HTTPClient != nil { + client.Client = config.HTTPClient + } + + client.Client = clientdebug.Wrap(client.Client) if config.Server != "" { client.Server = config.Server @@ -107,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) @@ -118,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, @@ -139,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) @@ -147,11 +161,11 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { for _, rec := range records { if rec.Fqdn == info.EffectiveFQDN && rec.Type == "TXT" { - // TODO(ldez) replace domain by FQDN to follow CNAME. request := &namecom.DeleteRecordRequest{ DomainName: domain, ID: rec.ID, } + _, err := d.client.DeleteRecord(request) if err != nil { return fmt.Errorf("namedotcom: %w", err) @@ -175,6 +189,7 @@ func (d *DNSProvider) getRecords(domain string) ([]*namecom.Record, error) { } var records []*namecom.Record + for request.Page > 0 { response, err := d.client.ListRecords(request) if err != nil { diff --git a/providers/dns/namedotcom/namedotcom.toml b/providers/dns/namedotcom/namedotcom.toml index 768164cf8..3651c424b 100644 --- a/providers/dns/namedotcom/namedotcom.toml +++ b/providers/dns/namedotcom/namedotcom.toml @@ -7,7 +7,7 @@ Since = "v0.5.0" Example = ''' NAMECOM_USERNAME=foo.bar \ NAMECOM_API_TOKEN=a379a6f6eeafb9a55e378c118034e2751e682fab \ -lego --email you@example.com --dns namedotcom -d '*.example.com' -d example.com run +lego --dns namedotcom -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,10 +15,10 @@ lego --email you@example.com --dns namedotcom -d '*.example.com' -d example.com NAMECOM_USERNAME = "Username" NAMECOM_API_TOKEN = "API token" [Configuration.Additional] - NAMECOM_POLLING_INTERVAL = "Time between DNS propagation check" - NAMECOM_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - NAMECOM_TTL = "The TTL of the TXT record used for the DNS challenge" - NAMECOM_HTTP_TIMEOUT = "API request timeout" + NAMECOM_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)" + NAMECOM_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 900)" + NAMECOM_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + NAMECOM_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://www.name.com/api-docs/DNS" diff --git a/providers/dns/namedotcom/namedotcom_test.go b/providers/dns/namedotcom/namedotcom_test.go index c7d4deaa1..da9878bdc 100644 --- a/providers/dns/namedotcom/namedotcom_test.go +++ b/providers/dns/namedotcom/namedotcom_test.go @@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -131,6 +132,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -144,6 +146,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/namesilo/namesilo.go b/providers/dns/namesilo/namesilo.go index 450c3d68c..0297b4e1c 100644 --- a/providers/dns/namesilo/namesilo.go +++ b/providers/dns/namesilo/namesilo.go @@ -2,20 +2,18 @@ package namesilo import ( + "context" "errors" "fmt" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/nrdcg/namesilo" ) -const ( - defaultTTL = 3600 - maxTTL = 2592000 -) - // Environment variables names. const ( envNamespace = "NAMESILO_" @@ -27,6 +25,13 @@ const ( EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +const ( + defaultTTL = 3600 + maxTTL = 2592000 +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -76,12 +81,15 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, fmt.Errorf("namesilo: TTL should be in [%d, %d]", defaultTTL, maxTTL) } - transport, err := namesilo.NewTokenTransport(config.APIKey) - if err != nil { - return nil, fmt.Errorf("namesilo: %w", err) + if config.APIKey == "" { + return nil, errors.New("namesilo: credentials missing") } - return &DNSProvider{client: namesilo.NewClient(transport.Client()), config: config}, nil + client := namesilo.NewClient(config.APIKey) + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. @@ -100,7 +108,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("namesilo: %w", err) } - _, err = d.client.DnsAddRecord(&namesilo.DnsAddRecordParams{ + _, err = d.client.DnsAddRecord(context.Background(), &namesilo.DnsAddRecordParams{ Domain: zoneName, Type: "TXT", Host: subdomain, @@ -110,11 +118,14 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("namesilo: failed to add record %w", err) } + return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) @@ -124,7 +135,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { zoneName := dns01.UnFqdn(zone) - resp, err := d.client.DnsListRecords(&namesilo.DnsListRecordsParams{Domain: zoneName}) + resp, err := d.client.DnsListRecords(ctx, &namesilo.DnsListRecordsParams{Domain: zoneName}) if err != nil { return fmt.Errorf("namesilo: %w", err) } @@ -136,7 +147,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { for _, r := range resp.Reply.ResourceRecord { if r.Type == "TXT" && r.Value == info.Value && (r.Host == subdomain || r.Host == dns01.UnFqdn(info.EffectiveFQDN)) { - _, err := d.client.DnsDeleteRecord(&namesilo.DnsDeleteRecordParams{Domain: zoneName, ID: r.RecordID}) + _, err := d.client.DnsDeleteRecord(ctx, &namesilo.DnsDeleteRecordParams{Domain: zoneName, ID: r.RecordID}) if err != nil { return fmt.Errorf("namesilo: %w", err) } diff --git a/providers/dns/namesilo/namesilo.toml b/providers/dns/namesilo/namesilo.toml index 991e78fcc..113ddb5c5 100644 --- a/providers/dns/namesilo/namesilo.toml +++ b/providers/dns/namesilo/namesilo.toml @@ -6,16 +6,16 @@ Since = "v2.7.0" Example = ''' NAMESILO_API_KEY=b9841238feb177a84330febba8a83208921177bffe733 \ -lego --email you@example.com --dns namesilo -d '*.example.com' -d example.com run +lego --dns namesilo -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NAMESILO_API_KEY = "Client ID" [Configuration.Additional] - NAMESILO_POLLING_INTERVAL = "Time between DNS propagation check" - NAMESILO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation, it is better to set larger than 15m" - NAMESILO_TTL = "The TTL of the TXT record used for the DNS challenge, should be in [3600, 2592000]" + NAMESILO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + NAMESILO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60), it is better to set larger than 15 minutes" + NAMESILO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600), should be in [3600, 2592000]" [Links] API = "https://www.namesilo.com/api_reference.php" diff --git a/providers/dns/namesilo/namesilo_test.go b/providers/dns/namesilo/namesilo_test.go index 4b01d7388..09eacd035 100644 --- a/providers/dns/namesilo/namesilo_test.go +++ b/providers/dns/namesilo/namesilo_test.go @@ -45,6 +45,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -77,7 +78,7 @@ func TestNewDNSProviderConfig(t *testing.T) { { desc: "missing API key", ttl: defaultTTL, - expected: "namesilo: credentials missing: API key", + expected: "namesilo: credentials missing", }, { desc: "unavailable TTL", @@ -112,6 +113,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/namesurfer/internal/client.go b/providers/dns/namesurfer/internal/client.go new file mode 100644 index 000000000..e40a7988c --- /dev/null +++ b/providers/dns/namesurfer/internal/client.go @@ -0,0 +1,226 @@ +package internal + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +type Client struct { + apiKey string + apiSecret string + + BaseURL *url.URL + HTTPClient *http.Client +} + +func NewClient(baseURL, apiKey, apiSecret string) (*Client, error) { + if apiKey == "" || apiSecret == "" { + return nil, errors.New("credentials missing") + } + + if baseURL == "" { + return nil, errors.New("base URL missing") + } + + apiEndpoint, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + return &Client{ + apiKey: apiKey, + apiSecret: apiSecret, + BaseURL: apiEndpoint.JoinPath("jsonrpc10"), + HTTPClient: &http.Client{ + Timeout: 5 * time.Second, + }, + }, nil +} + +// AddDNSRecord adds a DNS record. +// http://95.128.3.201:8053/API/NSService_10#addDNSRecord +func (d *Client) AddDNSRecord(ctx context.Context, zoneName, viewName string, record DNSNode) error { + digest := d.computeDigest( + zoneName, + viewName, + record.Name, + record.Type, + strconv.Itoa(record.TTL), + record.Data, + ) + + // JSON-RPC 1.0 requires positional parameters array + params := []any{ + digest, + zoneName, + viewName, + record, + } + + var ok bool + + err := d.doRequest(ctx, "addDNSRecord", params, &ok) + if err != nil { + return err + } + + if !ok { + return errors.New("addDNSRecord failed") + } + + return nil +} + +// UpdateDNSHost updates a DNS host record. +// Passing an empty newNode removes the oldNode. +// http://95.128.3.201:8053/API/NSService_10#updateDNSHost +func (d *Client) UpdateDNSHost(ctx context.Context, zoneName, viewName string, oldNode, newNode DNSNode) error { + digest := d.computeDigest(zoneName, viewName) + + // JSON-RPC 1.0 requires positional parameters array + params := []any{ + digest, + zoneName, + viewName, + oldNode, + newNode, + } + + var ok bool + + err := d.doRequest(ctx, "updateDNSHost", params, &ok) + if err != nil { + return err + } + + if !ok { + return errors.New("updateDNSHost failed") + } + + return nil +} + +// SearchDNSHosts searches for DNS host records. +// http://95.128.3.201:8053/API/NSService_10#searchDNSHosts +func (d *Client) SearchDNSHosts(ctx context.Context, pattern string) ([]DNSNode, error) { + digest := d.computeDigest(pattern) + + // JSON-RPC 1.0 requires positional parameters array + params := []any{ + digest, + pattern, + } + + var nodes []DNSNode + + err := d.doRequest(ctx, "searchDNSHosts", params, &nodes) + if err != nil { + return nil, err + } + + return nodes, nil +} + +// ListZones lists DNS zones. +// http://95.128.3.201:8053/API/NSService_10#listZones +func (d *Client) ListZones(ctx context.Context, mode string) ([]DNSZone, error) { + digest := d.computeDigest() + + // JSON-RPC 1.0 requires positional parameters array + params := []any{ + digest, + mode, + } + + var zones []DNSZone + + err := d.doRequest(ctx, "listZones", params, &zones) + if err != nil { + return nil, err + } + + return zones, nil +} + +func (d *Client) doRequest(ctx context.Context, method string, params []any, result any) error { + payload := APIRequest{ + ID: 1, + Method: method, + Params: slices.Concat([]any{d.apiKey}, params), + } + + buf := new(bytes.Buffer) + + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return fmt.Errorf("failed to create request JSON body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.BaseURL.String(), buf) + if err != nil { + return fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := d.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + if resp.StatusCode/100 != 2 { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + var rpcResp APIResponse + + err = json.Unmarshal(raw, &rpcResp) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + if rpcResp.Error != nil { + return rpcResp.Error + } + + err = json.Unmarshal(rpcResp.Result, result) + if err != nil { + return fmt.Errorf("unable to unmarshal response: %w: %s", err, rpcResp.Result) + } + + return nil +} + +func (d *Client) computeDigest(parts ...string) string { + params := []string{d.apiKey} + params = append(params, parts...) + params = append(params, d.apiSecret) + + mac := hmac.New(sha256.New, []byte(d.apiSecret)) + mac.Write([]byte(strings.Join(params, "&"))) + + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/providers/dns/namesurfer/internal/client_test.go b/providers/dns/namesurfer/internal/client_test.go new file mode 100644 index 000000000..9e8f917bc --- /dev/null +++ b/providers/dns/namesurfer/internal/client_test.go @@ -0,0 +1,158 @@ +package internal + +import ( + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "user", "secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + ) +} + +func TestClient_AddDNSRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /jsonrpc10", + servermock.ResponseFromFixture("addDNSRecord.json"), + servermock.CheckRequestJSONBodyFromFixture("addDNSRecord-request.json"), + ). + Build(t) + + record := DNSNode{ + Name: "_acme-challenge", + Type: "TXT", + Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 300, + } + + err := client.AddDNSRecord(t.Context(), "example.com", "viewA", record) + require.NoError(t, err) +} + +func TestClient_AddDNSRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /jsonrpc10", + servermock.ResponseFromFixture("error.json"), + ). + Build(t) + + record := DNSNode{ + Name: "_acme-challenge", + Type: "TXT", + Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 300, + } + + err := client.AddDNSRecord(t.Context(), "example.com", "viewA", record) + require.EqualError(t, err, "code: Server.Keyfailure, "+ + "filename: service, line: 13, "+ + "message: Unknown keyname user, "+ + `detail: Traceback (most recent call last): File "/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py", line 159, in dispatch_request result = self.call_method(method,req_dict,tc,export_dict,log_line) File "/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py", line 96, in call_method result = getattr(service_class_instance,req_dict['methodname'])(*args) File "/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/ladonizer/decorator.py", line 77, in injector res = f(*args,**kw) File "/usr/local/namesurfer/webui2/webui/service/service10/NSService_10.py", line 502, in addDNSRecord key = validate_key(keyname, digest, [zonename, viewname, record.name, record.type, str(record.ttl), record.data]) File "/usr/local/namesurfer/webui2/webui/service/base/implementation.py", line 63, in validate_key raise ApiFault('Server.Keyfailure', 'Unknown keyname %s' % keyname) ApiFault: service(13): Unknown keyname user `) +} + +func TestClient_UpdateDNSHost(t *testing.T) { + client := mockBuilder(). + Route("POST /jsonrpc10", + servermock.ResponseFromFixture("updateDNSHost.json"), + servermock.CheckRequestJSONBodyFromFixture("updateDNSHost-request.json"), + ). + Build(t) + + record := DNSNode{ + Name: "_acme-challenge", + Type: "TXT", + Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 300, + } + + err := client.UpdateDNSHost(t.Context(), "example.com", "viewA", record, DNSNode{}) + require.NoError(t, err) +} + +func TestClient_SearchDNSHosts(t *testing.T) { + client := mockBuilder(). + Route("POST /jsonrpc10", + servermock.ResponseFromFixture("searchDNSHosts.json"), + servermock.CheckRequestJSONBodyFromFixture("searchDNSHosts-request.json"), + ). + Build(t) + + records, err := client.SearchDNSHosts(t.Context(), "value") + require.NoError(t, err) + + expected := []DNSNode{ + {Name: "foo", Type: "TXT", Data: "xxx", TTL: 300}, + {Name: "_acme-challenge", Type: "TXT", Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 300}, + {Name: "bar", Type: "A", Data: "yyy", TTL: 300}, + } + + assert.Equal(t, expected, records) +} + +func TestClient_ListZones(t *testing.T) { + client := mockBuilder(). + Route("POST /jsonrpc10", + servermock.ResponseFromFixture("listZones.json"), + servermock.CheckRequestJSONBodyFromFixture("listZones-request.json"), + ). + Build(t) + + zones, err := client.ListZones(t.Context(), "value") + require.NoError(t, err) + + expected := []DNSZone{ + {Name: "example.com", View: "viewA"}, + {Name: "example.org", View: "viewB"}, + {Name: "example.net", View: "viewC"}, + } + + assert.Equal(t, expected, zones) +} + +func TestClient_computeDigest(t *testing.T) { + client, err := NewClient("https://test.example.com", "testkey", "testsecret") + require.NoError(t, err) + + testCases := []struct { + desc string + parts []string + expected string + }{ + { + desc: "no parts", + parts: []string{}, + expected: "99b5dcdc19bfc0ce2af3fe848f4bcb6f7beb352e9599e8ba50544d86de567282", + }, + { + desc: "parts", + parts: []string{"zone.example.com", "default"}, + expected: "94efef76383889b1ae620582a25d1c3aa9bd9ba9ac4bdccdf4aefbc3ae6e8329", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + digest := client.computeDigest(test.parts...) + + assert.Equal(t, test.expected, digest) + }) + } +} diff --git a/providers/dns/namesurfer/internal/fixtures/addDNSRecord-request.json b/providers/dns/namesurfer/internal/fixtures/addDNSRecord-request.json new file mode 100644 index 000000000..660109aae --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/addDNSRecord-request.json @@ -0,0 +1,16 @@ +{ + "id": 1, + "method": "addDNSRecord", + "params": [ + "user", + "4fcc5fa29531709b0381c8debea127a6a26e71cb9491727876819cf5805c4990", + "example.com", + "viewA", + { + "name": "_acme-challenge", + "type": "TXT", + "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 300 + } + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/addDNSRecord.json b/providers/dns/namesurfer/internal/fixtures/addDNSRecord.json new file mode 100644 index 000000000..f41779e30 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/addDNSRecord.json @@ -0,0 +1,4 @@ +{ + "id": 1, + "result": true +} diff --git a/providers/dns/namesurfer/internal/fixtures/error.json b/providers/dns/namesurfer/internal/fixtures/error.json new file mode 100644 index 000000000..8ddf8df25 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/error.json @@ -0,0 +1,24 @@ +{ + "result": null, + "error": { + "filename": "service", + "lineno": 13, + "code": "Server.Keyfailure", + "string": "Unknown keyname user", + "detail": [ + "Traceback (most recent call last):", + " File \"/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py\", line 159, in dispatch_request", + " result = self.call_method(method,req_dict,tc,export_dict,log_line)", + " File \"/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py\", line 96, in call_method", + " result = getattr(service_class_instance,req_dict['methodname'])(*args)", + " File \"/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/ladonizer/decorator.py\", line 77, in injector", + " res = f(*args,**kw)", + " File \"/usr/local/namesurfer/webui2/webui/service/service10/NSService_10.py\", line 502, in addDNSRecord", + " key = validate_key(keyname, digest, [zonename, viewname, record.name, record.type, str(record.ttl), record.data])", + " File \"/usr/local/namesurfer/webui2/webui/service/base/implementation.py\", line 63, in validate_key", + " raise ApiFault('Server.Keyfailure', 'Unknown keyname %s' % keyname)", + "ApiFault: service(13): Unknown keyname user", + "" + ] + } +} diff --git a/providers/dns/namesurfer/internal/fixtures/listZones-request.json b/providers/dns/namesurfer/internal/fixtures/listZones-request.json new file mode 100644 index 000000000..06689de7a --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/listZones-request.json @@ -0,0 +1,9 @@ +{ + "id": 1, + "method": "listZones", + "params": [ + "user", + "2739461ea1a3dc51302993f724f40228409c53b78025d8d7b1d7bba3c1bf2d66", + "value" + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/listZones.json b/providers/dns/namesurfer/internal/fixtures/listZones.json new file mode 100644 index 000000000..37fa2053b --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/listZones.json @@ -0,0 +1,17 @@ +{ + "id": 1, + "result": [ + { + "name": "example.com", + "view": "viewA" + }, + { + "name": "example.org", + "view": "viewB" + }, + { + "name": "example.net", + "view": "viewC" + } + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/searchDNSHosts-request.json b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts-request.json new file mode 100644 index 000000000..4a88340e2 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts-request.json @@ -0,0 +1,9 @@ +{ + "id": 1, + "method": "searchDNSHosts", + "params": [ + "user", + "02cf1a2f6e124507d16738d595f583932185313fc96afc2d8404960acaec29b4", + "value" + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/searchDNSHosts.json b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts.json new file mode 100644 index 000000000..822459148 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts.json @@ -0,0 +1,23 @@ +{ + "id": 1, + "result": [ + { + "name": "foo", + "type": "TXT", + "data": "xxx", + "ttl": 300 + }, + { + "name": "_acme-challenge", + "type": "TXT", + "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 300 + }, + { + "name": "bar", + "type": "A", + "data": "yyy", + "ttl": 300 + } + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json b/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json new file mode 100644 index 000000000..494de20c6 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json @@ -0,0 +1,22 @@ +{ + "id": 1, + "method": "updateDNSHost", + "params": [ + "user", + "510e63288ac874c1d5ba313a9411591daa346e5621fb0153263adc278794e378", + "example.com", + "viewA", + { + "name": "_acme-challenge", + "type": "TXT", + "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 300 + }, + { + "name": "", + "type": "", + "data": "", + "ttl": 0 + } + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/updateDNSHost.json b/providers/dns/namesurfer/internal/fixtures/updateDNSHost.json new file mode 100644 index 000000000..f41779e30 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/updateDNSHost.json @@ -0,0 +1,4 @@ +{ + "id": 1, + "result": true +} diff --git a/providers/dns/namesurfer/internal/types.go b/providers/dns/namesurfer/internal/types.go new file mode 100644 index 000000000..d364c1876 --- /dev/null +++ b/providers/dns/namesurfer/internal/types.go @@ -0,0 +1,72 @@ +package internal + +import ( + "encoding/json" + "fmt" + "strings" +) + +// DNSNode represents a DNS record. +// http://95.128.3.201:8053/API/NSService_10#DNSNode +type DNSNode struct { + Name string `json:"name"` + Type string `json:"type"` + Data string `json:"data"` + TTL int `json:"ttl"` +} + +// DNSZone represents a DNS zone. +// http://95.128.3.201:8053/API/NSService_10#DNSZone +type DNSZone struct { + Name string `json:"name,omitempty"` + View string `json:"view,omitempty"` +} + +// APIRequest represents a JSON-RPC request. +// https://www.jsonrpc.org/specification_v1#a1.1Requestmethodinvocation +type APIRequest struct { + ID any `json:"id"` // Can be int or string depending on API + Method string `json:"method"` + Params []any `json:"params"` +} + +// APIResponse represents a JSON-RPC response. +// https://www.jsonrpc.org/specification_v1#a1.2Response +type APIResponse struct { + ID any `json:"id"` // Can be int or string depending on API + Result json.RawMessage `json:"result"` + Error *APIError `json:"error"` +} + +// APIError represents an error. +type APIError struct { + Code any `json:"code"` // Can be int or string depending on API + Filename string `json:"filename"` + LineNumber int `json:"lineno"` + Message string `json:"string"` + Detail []string `json:"detail"` +} + +func (e *APIError) Error() string { + msg := new(strings.Builder) + + _, _ = fmt.Fprintf(msg, "code: %v", e.Code) + + if e.Filename != "" { + _, _ = fmt.Fprintf(msg, ", filename: %s", e.Filename) + } + + if e.LineNumber > 0 { + _, _ = fmt.Fprintf(msg, ", line: %d", e.LineNumber) + } + + if e.Message != "" { + _, _ = fmt.Fprintf(msg, ", message: %s", e.Message) + } + + if len(e.Detail) > 0 { + _, _ = fmt.Fprintf(msg, ", detail: %v", strings.Join(e.Detail, " ")) + } + + return msg.String() +} diff --git a/providers/dns/namesurfer/namesurfer.go b/providers/dns/namesurfer/namesurfer.go new file mode 100644 index 000000000..6b7f48402 --- /dev/null +++ b/providers/dns/namesurfer/namesurfer.go @@ -0,0 +1,214 @@ +// Package namesurfer implements a DNS provider for solving the DNS-01 challenge using FusionLayer NameSurfer API. +package namesurfer + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/namesurfer/internal" +) + +// Environment variables names. +const ( + envNamespace = "NAMESURFER_" + + EnvBaseURL = envNamespace + "BASE_URL" + EnvAPIKey = envNamespace + "API_KEY" + EnvAPISecret = envNamespace + "API_SECRET" + EnvView = envNamespace + "VIEW" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" + EnvInsecureSkipVerify = envNamespace + "INSECURE_SKIP_VERIFY" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + BaseURL string + APIKey string + APISecret string + View string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, 300), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + zones map[string]string + zonesMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for FusionLayer NameSurfer. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvBaseURL, EnvAPIKey, EnvAPISecret) + if err != nil { + return nil, fmt.Errorf("namesurfer: %w", err) + } + + config := NewDefaultConfig() + config.BaseURL = values[EnvBaseURL] + config.APIKey = values[EnvAPIKey] + config.APISecret = values[EnvAPISecret] + config.View = env.GetOrDefaultString(EnvView, "") + + if env.GetOrDefaultBool(EnvInsecureSkipVerify, false) { + config.HTTPClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for FusionLayer NameSurfer. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("namesurfer: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.BaseURL, config.APIKey, config.APISecret) + if err != nil { + return nil, fmt.Errorf("namesurfer: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + zones: make(map[string]string), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + zone, err := d.findZone(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("namesurfer: %w", err) + } + + d.zonesMu.Lock() + d.zones[token] = zone + d.zonesMu.Unlock() + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) + if err != nil { + return fmt.Errorf("namesurfer: %w", err) + } + + record := internal.DNSNode{ + Name: subDomain, + Type: "TXT", + TTL: d.config.TTL, + Data: info.Value, + } + + err = d.client.AddDNSRecord(ctx, zone, d.config.View, record) + if err != nil { + return fmt.Errorf("namesurfer: add DNS record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + d.zonesMu.Lock() + zone, ok := d.zones[token] + d.zonesMu.Unlock() + + if !ok { + return fmt.Errorf("namesurfer: unknown zone for '%s'", info.EffectiveFQDN) + } + + d.zonesMu.Lock() + delete(d.zones, token) + d.zonesMu.Unlock() + + existing, err := d.client.SearchDNSHosts(ctx, dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("namesurfer: search DNS hosts: %w", err) + } + + for _, node := range existing { + if node.Type != "TXT" || node.Data != info.Value { + continue + } + + err = d.client.UpdateDNSHost(ctx, zone, d.config.View, node, internal.DNSNode{}) + if err != nil { + return fmt.Errorf("namesurfer: update DNS host: %w", err) + } + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (string, error) { + zones, err := d.client.ListZones(ctx, "forward") + if err != nil { + return "", fmt.Errorf("list zones: %w", err) + } + + domain := dns01.UnFqdn(fqdn) + + var zoneName string + + for _, zone := range zones { + if strings.HasSuffix(domain, zone.Name) && len(zone.Name) > len(zoneName) { + zoneName = zone.Name + } + } + + if zoneName == "" { + return "", fmt.Errorf("no zone found for %s", fqdn) + } + + return zoneName, nil +} diff --git a/providers/dns/namesurfer/namesurfer.toml b/providers/dns/namesurfer/namesurfer.toml new file mode 100644 index 000000000..fd914ec0c --- /dev/null +++ b/providers/dns/namesurfer/namesurfer.toml @@ -0,0 +1,28 @@ +Name = "FusionLayer NameSurfer" +Description = '''''' +URL = "https://www.fusionlayer.com/" +Code = "namesurfer" +Since = "v4.32.0" + +Example = ''' +NAMESURFER_BASE_URL=https://foo.example.com:8443/API/NSService_10 \ +NAMESURFER_API_KEY=xxx \ +NAMESURFER_API_SECRET=yyy \ +lego --dns namesurfer -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + NAMESURFER_BASE_URL = "The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10)" + NAMESURFER_API_KEY = "API key name" + NAMESURFER_API_SECRET = "API secret" + [Configuration.Additional] + NAMESURFER_VIEW = "DNS view name (optional, default: empty string)" + NAMESURFER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + NAMESURFER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + NAMESURFER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + NAMESURFER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + NAMESURFER_INSECURE_SKIP_VERIFY = "Whether to verify the API certificate" + +[Links] + API = "https://web.archive.org/web/20260213170737/http://95.128.3.201:8053/API/NSService_10" diff --git a/providers/dns/namesurfer/namesurfer_test.go b/providers/dns/namesurfer/namesurfer_test.go new file mode 100644 index 000000000..ce3aa37af --- /dev/null +++ b/providers/dns/namesurfer/namesurfer_test.go @@ -0,0 +1,174 @@ +package namesurfer + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvBaseURL, + EnvAPIKey, + EnvAPISecret, + EnvView, +).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvBaseURL: "https://example.com", + EnvAPIKey: "user", + EnvAPISecret: "secret", + }, + }, + { + desc: "missing base URL", + envVars: map[string]string{ + EnvBaseURL: "", + EnvAPIKey: "user", + EnvAPISecret: "secret", + }, + expected: "namesurfer: some credentials information are missing: NAMESURFER_BASE_URL", + }, + { + desc: "missing API key", + envVars: map[string]string{ + EnvBaseURL: "https://example.com", + EnvAPIKey: "", + EnvAPISecret: "secret", + }, + expected: "namesurfer: some credentials information are missing: NAMESURFER_API_KEY", + }, + { + desc: "missing API secret", + envVars: map[string]string{ + EnvBaseURL: "https://example.com", + EnvAPIKey: "user", + EnvAPISecret: "", + }, + expected: "namesurfer: some credentials information are missing: NAMESURFER_API_SECRET", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "namesurfer: some credentials information are missing: NAMESURFER_BASE_URL,NAMESURFER_API_KEY,NAMESURFER_API_SECRET", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + baseURL string + apiKey string + apiSecret string + expected string + }{ + { + desc: "success", + baseURL: "https://example.com", + apiKey: "user", + apiSecret: "secret", + }, + { + desc: "missing base URL", + apiKey: "user", + apiSecret: "secret", + expected: "namesurfer: base URL missing", + }, + { + desc: "missing API key", + baseURL: "https://example.com", + apiSecret: "secret", + expected: "namesurfer: credentials missing", + }, + { + desc: "missing API secret", + baseURL: "https://example.com", + apiKey: "user", + expected: "namesurfer: credentials missing", + }, + { + desc: "missing credentials", + expected: "namesurfer: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.BaseURL = test.baseURL + config.APIKey = test.apiKey + config.APISecret = test.apiSecret + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/nearlyfreespeech/internal/client.go b/providers/dns/nearlyfreespeech/internal/client.go index 08d8d511f..5d7e79fbe 100644 --- a/providers/dns/nearlyfreespeech/internal/client.go +++ b/providers/dns/nearlyfreespeech/internal/client.go @@ -34,7 +34,7 @@ type Client struct { HTTPClient *http.Client } -func NewClient(login string, apiKey string) *Client { +func NewClient(login, apiKey string) *Client { baseURL, _ := url.Parse(apiURL) return &Client{ @@ -46,7 +46,7 @@ func NewClient(login string, apiKey string) *Client { } } -func (c Client) AddRecord(ctx context.Context, domain string, record Record) error { +func (c *Client) AddRecord(ctx context.Context, domain string, record Record) error { endpoint := c.baseURL.JoinPath("dns", dns01.UnFqdn(domain), "addRR") params, err := querystring.Values(record) @@ -57,7 +57,7 @@ func (c Client) AddRecord(ctx context.Context, domain string, record Record) err return c.doRequest(ctx, endpoint, params) } -func (c Client) RemoveRecord(ctx context.Context, domain string, record Record) error { +func (c *Client) RemoveRecord(ctx context.Context, domain string, record Record) error { endpoint := c.baseURL.JoinPath("dns", dns01.UnFqdn(domain), "removeRR") params, err := querystring.Values(record) @@ -68,7 +68,7 @@ func (c Client) RemoveRecord(ctx context.Context, domain string, record Record) return c.doRequest(ctx, endpoint, params) } -func (c Client) doRequest(ctx context.Context, endpoint *url.URL, params url.Values) error { +func (c *Client) doRequest(ctx context.Context, endpoint *url.URL, params url.Values) error { payload := params.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(payload)) @@ -97,6 +97,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errAPI := &APIError{} + err := json.Unmarshal(raw, errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) @@ -114,11 +115,10 @@ func NewSigner() *Signer { return &Signer{saltShaker: getRandomSalt, clock: time.Now} } -func (c Signer) Sign(uri string, body, login, apiKey string) string { +func (c Signer) Sign(uri, body, login, apiKey string) string { // Header is "login;timestamp;salt;hash". // hash is SHA1("login;timestamp;salt;api-key;request-uri;body-hash") // and body-hash is SHA1(body). - bodyHash := sha1.Sum([]byte(body)) timestamp := strconv.FormatInt(c.clock().Unix(), 10) diff --git a/providers/dns/nearlyfreespeech/internal/client_test.go b/providers/dns/nearlyfreespeech/internal/client_test.go index 935ee4fff..26e4552be 100644 --- a/providers/dns/nearlyfreespeech/internal/client_test.go +++ b/providers/dns/nearlyfreespeech/internal/client_test.go @@ -1,27 +1,18 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" "testing" "time" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) @@ -29,66 +20,22 @@ func setupTest(t *testing.T) (*Client, *http.ServeMux) { client.signer.saltShaker = func() []byte { return []byte("0123456789ABCDEF") } client.signer.clock = func() time.Time { return time.Unix(1692475113, 0) } - return client, mux -} - -func testHandler(params map[string]string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - if req.Header.Get(authenticationHeader) == "" { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - err := req.ParseForm() - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - for k, v := range params { - if req.PostForm.Get(k) != v { - http.Error(rw, fmt.Sprintf("data: got %s want %s", k, v), http.StatusBadRequest) - return - } - } - } -} - -func testErrorHandler() http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - file, err := os.Open("./fixtures/error.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - rw.WriteHeader(http.StatusUnauthorized) - - _, _ = io.Copy(rw, file) - } + return client, nil } func TestClient_AddRecord(t *testing.T) { - client, mux := setupTest(t) - - params := map[string]string{ - "data": "txtTXTtxt", - "name": "sub", - "type": "TXT", - "ttl": "30", - } - - mux.Handle("/dns/example.com/addRR", testHandler(params)) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(). + With(authenticationHeader, "user;1692475113;0123456789ABCDEF;24a32faf74c7bd0525f560ff12a1c1fb6545bafc"), + ). + Route("POST /dns/example.com/addRR", nil, servermock.CheckForm().Strict(). + With("data", "txtTXTtxt"). + With("name", "sub"). + With("type", "TXT"). + With("ttl", "30"), + ). + Build(t) record := Record{ Name: "sub", @@ -97,14 +44,20 @@ func TestClient_AddRecord(t *testing.T) { TTL: 30, } - err := client.AddRecord(context.Background(), "example.com", record) + err := client.AddRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/dns/example.com/addRR", testErrorHandler()) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(). + With(authenticationHeader, "user;1692475113;0123456789ABCDEF;24a32faf74c7bd0525f560ff12a1c1fb6545bafc"), + ). + Route("POST /dns/example.com/addRR", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) record := Record{ Name: "sub", @@ -113,20 +66,23 @@ func TestClient_AddRecord_error(t *testing.T) { TTL: 30, } - err := client.AddRecord(context.Background(), "example.com", record) + err := client.AddRecord(t.Context(), "example.com", record) require.Error(t, err) } func TestClient_RemoveRecord(t *testing.T) { - client, mux := setupTest(t) - - params := map[string]string{ - "data": "txtTXTtxt", - "name": "sub", - "type": "TXT", - } - - mux.Handle("/dns/example.com/removeRR", testHandler(params)) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(). + With(authenticationHeader, "user;1692475113;0123456789ABCDEF;699f01f077ca487bd66ac370d6dfc5b122c65522"), + ). + Route("POST /dns/example.com/removeRR", nil, + servermock.CheckForm().Strict(). + With("data", "txtTXTtxt"). + With("name", "sub"). + With("type", "TXT"), + ). + Build(t) record := Record{ Name: "sub", @@ -134,14 +90,20 @@ func TestClient_RemoveRecord(t *testing.T) { Data: "txtTXTtxt", } - err := client.RemoveRecord(context.Background(), "example.com", record) + err := client.RemoveRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_RemoveRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/dns/example.com/removeRR", testErrorHandler()) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(). + With(authenticationHeader, "user;1692475113;0123456789ABCDEF;699f01f077ca487bd66ac370d6dfc5b122c65522"), + ). + Route("POST /dns/example.com/removeRR", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) record := Record{ Name: "sub", @@ -149,7 +111,7 @@ func TestClient_RemoveRecord_error(t *testing.T) { Data: "txtTXTtxt", } - err := client.RemoveRecord(context.Background(), "example.com", record) + err := client.RemoveRecord(t.Context(), "example.com", record) require.Error(t, err) } @@ -201,6 +163,7 @@ func TestSigner_Sign(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() + signer := NewSigner() signer.saltShaker = func() []byte { return []byte(test.salt) } signer.clock = func() time.Time { return time.Unix(test.now, 0) } diff --git a/providers/dns/nearlyfreespeech/nearlyfreespeech.go b/providers/dns/nearlyfreespeech/nearlyfreespeech.go index 8f94e0911..af5e5363c 100644 --- a/providers/dns/nearlyfreespeech/nearlyfreespeech.go +++ b/providers/dns/nearlyfreespeech/nearlyfreespeech.go @@ -8,8 +8,10 @@ import ( "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/nearlyfreespeech/internal" ) @@ -27,6 +29,8 @@ const ( EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -89,6 +93,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, diff --git a/providers/dns/nearlyfreespeech/nearlyfreespeech.toml b/providers/dns/nearlyfreespeech/nearlyfreespeech.toml index 985df6cba..3a1e25942 100644 --- a/providers/dns/nearlyfreespeech/nearlyfreespeech.toml +++ b/providers/dns/nearlyfreespeech/nearlyfreespeech.toml @@ -7,7 +7,7 @@ Since = "v4.8.0" Example = ''' NEARLYFREESPEECH_API_KEY=xxxxxx \ NEARLYFREESPEECH_LOGIN=xxxx \ -lego --email you@example.com --dns nearlyfreespeech -d '*.example.com' -d example.com run +lego --dns nearlyfreespeech -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,11 +15,11 @@ lego --email you@example.com --dns nearlyfreespeech -d '*.example.com' -d exampl NEARLYFREESPEECH_API_KEY = "API Key for API requests" NEARLYFREESPEECH_LOGIN = "Username for API requests" [Configuration.Additional] - NEARLYFREESPEECH_POLLING_INTERVAL = "Time between DNS propagation check" - NEARLYFREESPEECH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - NEARLYFREESPEECH_TTL = "The TTL of the TXT record used for the DNS challenge" - NEARLYFREESPEECH_HTTP_TIMEOUT = "API request timeout" - NEARLYFREESPEECH_SEQUENCE_INTERVAL = "Time between sequential requests" + NEARLYFREESPEECH_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + NEARLYFREESPEECH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + NEARLYFREESPEECH_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" + NEARLYFREESPEECH_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" + NEARLYFREESPEECH_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://members.nearlyfreespeech.net/wiki/API/Reference" diff --git a/providers/dns/nearlyfreespeech/nearlyfreespeech_test.go b/providers/dns/nearlyfreespeech/nearlyfreespeech_test.go index adc7efe1e..b67b350e9 100644 --- a/providers/dns/nearlyfreespeech/nearlyfreespeech_test.go +++ b/providers/dns/nearlyfreespeech/nearlyfreespeech_test.go @@ -54,6 +54,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -126,6 +127,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -139,6 +141,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/neodigit/neodigit.go b/providers/dns/neodigit/neodigit.go new file mode 100644 index 000000000..d41846307 --- /dev/null +++ b/providers/dns/neodigit/neodigit.go @@ -0,0 +1,103 @@ +// Package neodigit implements a DNS provider for solving the DNS-01 challenge using Neodigit DNS. +package neodigit + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/tecnocratica" +) + +// Environment variables names. +const ( + envNamespace = "NEODIGIT_" + + EnvToken = envNamespace + "TOKEN" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +const defaultBaseURL = "https://api.neodigit.net/v1" + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config = tecnocratica.Config + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + prv challenge.ProviderTimeout +} + +// NewDNSProvider returns a DNSProvider instance configured for Neodigit. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvToken) + if err != nil { + return nil, fmt.Errorf("neodigit: %w", err) + } + + config := NewDefaultConfig() + config.Token = values[EnvToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Neodigit. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("neodigit: the configuration of the DNS provider is nil") + } + + provider, err := tecnocratica.NewDNSProviderConfig(config, defaultBaseURL) + if err != nil { + return nil, fmt.Errorf("neodigit: %w", err) + } + + return &DNSProvider{prv: provider}, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + err := d.prv.Present(domain, token, keyAuth) + if err != nil { + return fmt.Errorf("neodigit: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + err := d.prv.CleanUp(domain, token, keyAuth) + if err != nil { + return fmt.Errorf("neodigit: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.prv.Timeout() +} diff --git a/providers/dns/neodigit/neodigit.toml b/providers/dns/neodigit/neodigit.toml new file mode 100644 index 000000000..91b3cfb07 --- /dev/null +++ b/providers/dns/neodigit/neodigit.toml @@ -0,0 +1,22 @@ +Name = "Neodigit" +Description = '''''' +URL = "https://www.neodigit.net" +Code = "neodigit" +Since = "v4.30.0" + +Example = ''' +NEODIGIT_TOKEN=xxxxxx \ +lego --dns neodigit -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + NEODIGIT_TOKEN = "API token" + [Configuration.Additional] + NEODIGIT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + NEODIGIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" + NEODIGIT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + NEODIGIT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://developers.neodigit.net/#dns" diff --git a/providers/dns/neodigit/neodigit_test.go b/providers/dns/neodigit/neodigit_test.go new file mode 100644 index 000000000..39f67c59c --- /dev/null +++ b/providers/dns/neodigit/neodigit_test.go @@ -0,0 +1,116 @@ +package neodigit + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvToken: "secret", + }, + }, + { + desc: "missing credentials: token", + envVars: map[string]string{ + EnvToken: "", + }, + expected: "neodigit: some credentials information are missing: NEODIGIT_TOKEN", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.prv) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + token string + expected string + }{ + { + desc: "success", + token: "secret", + }, + { + desc: "missing token", + expected: "neodigit: missing credentials", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Token = test.token + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.prv) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/netcup/internal/client.go b/providers/dns/netcup/internal/client.go index 9573c09c8..1287a8d7a 100644 --- a/providers/dns/netcup/internal/client.go +++ b/providers/dns/netcup/internal/client.go @@ -80,6 +80,7 @@ func (c *Client) GetDNSRecords(ctx context.Context, hostname string) ([]DNSRecor } var responseData InfoDNSRecordsResponse + err := c.doRequest(ctx, payload, &responseData) if err != nil { return nil, fmt.Errorf("error when sending the request: %w", err) @@ -139,10 +140,11 @@ func GetDNSRecordIdx(records []DNSRecord, record DNSRecord) (int, error) { return index, nil } } + return -1, errors.New("no DNS Record found") } -func newJSONRequest(ctx context.Context, method string, endpoint string, payload any) (*http.Request, error) { +func newJSONRequest(ctx context.Context, method, endpoint string, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { @@ -173,6 +175,7 @@ func unmarshalResponseMsg(req *http.Request, resp *http.Response) (*ResponseMsg, } var respMsg ResponseMsg + err = json.Unmarshal(raw, &respMsg) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) diff --git a/providers/dns/netcup/internal/client_live_test.go b/providers/dns/netcup/internal/client_live_test.go new file mode 100644 index 000000000..68621882e --- /dev/null +++ b/providers/dns/netcup/internal/client_live_test.go @@ -0,0 +1,137 @@ +package internal + +import ( + "fmt" + "strconv" + "strings" + "testing" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var envTest = tester.NewEnvTest( + "NETCUP_CUSTOMER_NUMBER", + "NETCUP_API_KEY", + "NETCUP_API_PASSWORD"). + WithDomain("NETCUP_DOMAIN") + +func TestClient_GetDNSRecords_Live(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + // Setup + envTest.RestoreEnv() + + client, err := NewClient( + envTest.GetValue("NETCUP_CUSTOMER_NUMBER"), + envTest.GetValue("NETCUP_API_KEY"), + envTest.GetValue("NETCUP_API_PASSWORD")) + require.NoError(t, err) + + ctx, err := client.CreateSessionContext(t.Context()) + require.NoError(t, err) + + info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==") + + zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + require.NoError(t, err) + + zone = dns01.UnFqdn(zone) + + // TestMethod + _, err = client.GetDNSRecords(ctx, zone) + require.NoError(t, err) + + // Tear down + err = client.Logout(ctx) + require.NoError(t, err) +} + +func TestClient_UpdateDNSRecord_Live(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + // Setup + envTest.RestoreEnv() + + client, err := NewClient( + envTest.GetValue("NETCUP_CUSTOMER_NUMBER"), + envTest.GetValue("NETCUP_API_KEY"), + envTest.GetValue("NETCUP_API_PASSWORD")) + require.NoError(t, err) + + ctx, err := client.CreateSessionContext(t.Context()) + require.NoError(t, err) + + info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==") + + zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + require.NotErrorIs(t, err, fmt.Errorf("error finding DNSZone, %w", err)) + + hostname := strings.Replace(info.EffectiveFQDN, "."+zone, "", 1) + + record := DNSRecord{ + Hostname: hostname, + RecordType: "TXT", + Destination: "asdf5678", + DeleteRecord: false, + } + + // test + zone = dns01.UnFqdn(zone) + + err = client.UpdateDNSRecord(ctx, zone, []DNSRecord{record}) + require.NoError(t, err) + + records, err := client.GetDNSRecords(ctx, zone) + require.NoError(t, err) + + recordIdx, err := GetDNSRecordIdx(records, record) + require.NoError(t, err) + + assert.Equal(t, record.Hostname, records[recordIdx].Hostname) + assert.Equal(t, record.RecordType, records[recordIdx].RecordType) + assert.Equal(t, record.Destination, records[recordIdx].Destination) + assert.Equal(t, record.DeleteRecord, records[recordIdx].DeleteRecord) + + records[recordIdx].DeleteRecord = true + + // Tear down + err = client.UpdateDNSRecord(ctx, envTest.GetDomain(), []DNSRecord{records[recordIdx]}) + require.NoError(t, err) + + err = client.Logout(ctx) + require.NoError(t, err) +} + +func TestLiveClientAuth(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + // Setup + envTest.RestoreEnv() + + client, err := NewClient( + envTest.GetValue("NETCUP_CUSTOMER_NUMBER"), + envTest.GetValue("NETCUP_API_KEY"), + envTest.GetValue("NETCUP_API_PASSWORD")) + require.NoError(t, err) + + for i := range 4 { + t.Run("Test_"+strconv.Itoa(i+1), func(t *testing.T) { + t.Parallel() + + ctx, err := client.CreateSessionContext(t.Context()) + require.NoError(t, err) + + err = client.Logout(ctx) + require.NoError(t, err) + }) + } +} diff --git a/providers/dns/netcup/internal/client_test.go b/providers/dns/netcup/internal/client_test.go index 0e028e881..83c59460e 100644 --- a/providers/dns/netcup/internal/client_test.go +++ b/providers/dns/netcup/internal/client_test.go @@ -1,41 +1,30 @@ package internal import ( - "bytes" - "context" - "fmt" - "io" "net/http" "net/http/httptest" - "strings" "testing" - "github.com/go-acme/lego/v4/challenge/dns01" - "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -var envTest = tester.NewEnvTest( - "NETCUP_CUSTOMER_NUMBER", - "NETCUP_API_KEY", - "NETCUP_API_PASSWORD"). - WithDomain("NETCUP_DOMAIN") +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("a", "b", "c") + if err != nil { + return nil, err + } -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() + client.baseURL = server.URL + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client, err := NewClient("a", "b", "c") - require.NoError(t, err) - - client.baseURL = server.URL - client.HTTPClient = server.Client() - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestGetDNSRecordIdx(t *testing.T) { @@ -140,59 +129,10 @@ func TestGetDNSRecordIdx(t *testing.T) { } func TestClient_GetDNSRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - raw, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if string(bytes.TrimSpace(raw)) != `{"action":"infoDnsRecords","param":{"domainname":"example.com","customernumber":"a","apikey":"b","apisessionid":""}}` { - http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest) - return - } - - response := ` - { - "serverrequestid":"srv-request-id", - "clientrequestid":"", - "action":"infoDnsRecords", - "status":"success", - "statuscode":2000, - "shortmessage":"Login successful", - "longmessage":"Session has been created successful.", - "responsedata":{ - "apisessionid":"api-session-id", - "dnsrecords":[ - { - "id":"1", - "hostname":"example.com", - "type":"TXT", - "priority":"1", - "destination":"bGVnbzE=", - "state":"yes", - "ttl":300 - }, - { - "id":"2", - "hostname":"example2.com", - "type":"TXT", - "priority":"1", - "destination":"bGVnbw==", - "state":"yes", - "ttl":300 - } - ] - } - }` - _, err = rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("get_dns_records.json"), + servermock.CheckRequestJSONBodyFromFixture("get_dns_records-request.json")). + Build(t) expected := []DNSRecord{{ ID: 1, @@ -202,7 +142,6 @@ func TestClient_GetDNSRecords(t *testing.T) { Destination: "bGVnbzE=", DeleteRecord: false, State: "yes", - TTL: 300, }, { ID: 2, Hostname: "example2.com", @@ -211,10 +150,9 @@ func TestClient_GetDNSRecords(t *testing.T) { Destination: "bGVnbw==", DeleteRecord: false, State: "yes", - TTL: 300, }} - records, err := client.GetDNSRecords(context.Background(), "example.com") + records, err := client.GetDNSRecords(t.Context(), "example.com") require.NoError(t, err) assert.Equal(t, expected, records) @@ -222,67 +160,24 @@ func TestClient_GetDNSRecords(t *testing.T) { func TestClient_GetDNSRecords_errors(t *testing.T) { testCases := []struct { - desc string - handler func(rw http.ResponseWriter, req *http.Request) + desc string + handler http.Handler + expected string }{ { - desc: "HTTP error", - handler: func(rw http.ResponseWriter, _ *http.Request) { - http.Error(rw, "error message", http.StatusInternalServerError) - }, + desc: "HTTP error", + handler: servermock.Noop().WithStatusCode(http.StatusInternalServerError), + expected: `error when sending the request: unexpected status code: [status code: 500] body: `, }, { - desc: "API error", - handler: func(rw http.ResponseWriter, _ *http.Request) { - response := ` - { - "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE", - "clientrequestid":"", - "action":"infoDnsRecords", - "status":"error", - "statuscode":4013, - "shortmessage":"Validation Error.", - "longmessage":"Message is empty.", - "responsedata":"" - }` - _, err := rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }, + desc: "API error", + handler: servermock.ResponseFromFixture("get_dns_records_error.json"), + expected: `error when sending the request: an error occurred during the action infoDnsRecords: [Status=error, StatusCode=4013, ShortMessage=Validation Error., LongMessage=Message is empty.]`, }, { - desc: "responsedata marshaling error", - handler: func(rw http.ResponseWriter, req *http.Request) { - raw, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if string(raw) != `{"action":"infoDnsRecords","param":{"domainname":"example.com","customernumber":"a","apikey":"b","apisessionid":"api-session-id"}}` { - http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest) - return - } - - response := ` - { - "serverrequestid":"srv-request-id", - "clientrequestid":"", - "action":"infoDnsRecords", - "status":"success", - "statuscode":2000, - "shortmessage":"Login successful", - "longmessage":"Session has been created successful.", - "responsedata":"" - }` - _, err = rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }, + desc: "responsedata marshaling error", + handler: servermock.ResponseFromFixture("get_dns_records_error_unmarshal.json"), + expected: `error when sending the request: unable to unmarshal response: [status code: 200] body: "" error: json: cannot unmarshal string into Go value of type internal.InfoDNSRecordsResponse`, }, } @@ -290,105 +185,13 @@ func TestClient_GetDNSRecords_errors(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client, mux := setupTest(t) + client := mockBuilder(). + Route("POST /", test.handler). + Build(t) - mux.HandleFunc("/", test.handler) - - records, err := client.GetDNSRecords(context.Background(), "example.com") - require.Error(t, err) + records, err := client.GetDNSRecords(t.Context(), "example.com") + require.EqualError(t, err, test.expected) assert.Empty(t, records) }) } } - -func TestClient_GetDNSRecords_Live(t *testing.T) { - if !envTest.IsLiveTest() { - t.Skip("skipping live test") - } - - // Setup - envTest.RestoreEnv() - - client, err := NewClient( - envTest.GetValue("NETCUP_CUSTOMER_NUMBER"), - envTest.GetValue("NETCUP_API_KEY"), - envTest.GetValue("NETCUP_API_PASSWORD")) - require.NoError(t, err) - - ctx, err := client.CreateSessionContext(context.Background()) - require.NoError(t, err) - - info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==") - - zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) - require.NoError(t, err, "error finding DNSZone") - - zone = dns01.UnFqdn(zone) - - // TestMethod - _, err = client.GetDNSRecords(ctx, zone) - require.NoError(t, err) - - // Tear down - err = client.Logout(ctx) - require.NoError(t, err) -} - -func TestClient_UpdateDNSRecord_Live(t *testing.T) { - if !envTest.IsLiveTest() { - t.Skip("skipping live test") - } - - // Setup - envTest.RestoreEnv() - - client, err := NewClient( - envTest.GetValue("NETCUP_CUSTOMER_NUMBER"), - envTest.GetValue("NETCUP_API_KEY"), - envTest.GetValue("NETCUP_API_PASSWORD")) - require.NoError(t, err) - - ctx, err := client.CreateSessionContext(context.Background()) - require.NoError(t, err) - - info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==") - - zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) - require.NotErrorIs(t, err, fmt.Errorf("error finding DNSZone, %w", err)) - - hostname := strings.Replace(info.EffectiveFQDN, "."+zone, "", 1) - - record := DNSRecord{ - Hostname: hostname, - RecordType: "TXT", - Destination: "asdf5678", - DeleteRecord: false, - TTL: 120, - } - - // test - zone = dns01.UnFqdn(zone) - - err = client.UpdateDNSRecord(ctx, zone, []DNSRecord{record}) - require.NoError(t, err) - - records, err := client.GetDNSRecords(ctx, zone) - require.NoError(t, err) - - recordIdx, err := GetDNSRecordIdx(records, record) - require.NoError(t, err) - - assert.Equal(t, record.Hostname, records[recordIdx].Hostname) - assert.Equal(t, record.RecordType, records[recordIdx].RecordType) - assert.Equal(t, record.Destination, records[recordIdx].Destination) - assert.Equal(t, record.DeleteRecord, records[recordIdx].DeleteRecord) - - records[recordIdx].DeleteRecord = true - - // Tear down - err = client.UpdateDNSRecord(ctx, envTest.GetDomain(), []DNSRecord{records[recordIdx]}) - require.NoError(t, err, "Did not remove record! Please do so yourself.") - - err = client.Logout(ctx) - require.NoError(t, err) -} diff --git a/providers/dns/netcup/internal/fixtures/get_dns_records-request.json b/providers/dns/netcup/internal/fixtures/get_dns_records-request.json new file mode 100644 index 000000000..bcf8e5310 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/get_dns_records-request.json @@ -0,0 +1,9 @@ +{ + "action": "infoDnsRecords", + "param": { + "domainname": "example.com", + "customernumber": "a", + "apikey": "b", + "apisessionid": "" + } +} diff --git a/providers/dns/netcup/internal/fixtures/get_dns_records.json b/providers/dns/netcup/internal/fixtures/get_dns_records.json new file mode 100644 index 000000000..e521a8e24 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/get_dns_records.json @@ -0,0 +1,32 @@ +{ + "serverrequestid": "srv-request-id", + "clientrequestid": "", + "action": "infoDnsRecords", + "status": "success", + "statuscode": 2000, + "shortmessage": "Login successful", + "longmessage": "Session has been created successful.", + "responsedata": { + "apisessionid": "api-session-id", + "dnsrecords": [ + { + "id": "1", + "hostname": "example.com", + "type": "TXT", + "priority": "1", + "destination": "bGVnbzE=", + "state": "yes", + "ttl": 300 + }, + { + "id": "2", + "hostname": "example2.com", + "type": "TXT", + "priority": "1", + "destination": "bGVnbw==", + "state": "yes", + "ttl": 300 + } + ] + } +} diff --git a/providers/dns/netcup/internal/fixtures/get_dns_records_error.json b/providers/dns/netcup/internal/fixtures/get_dns_records_error.json new file mode 100644 index 000000000..3ba472366 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/get_dns_records_error.json @@ -0,0 +1,10 @@ +{ + "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE", + "clientrequestid":"", + "action":"infoDnsRecords", + "status":"error", + "statuscode":4013, + "shortmessage":"Validation Error.", + "longmessage":"Message is empty.", + "responsedata":"" +} diff --git a/providers/dns/netcup/internal/fixtures/get_dns_records_error_unmarshal.json b/providers/dns/netcup/internal/fixtures/get_dns_records_error_unmarshal.json new file mode 100644 index 000000000..f8f91329f --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/get_dns_records_error_unmarshal.json @@ -0,0 +1,10 @@ +{ + "serverrequestid":"srv-request-id", + "clientrequestid":"", + "action":"infoDnsRecords", + "status":"success", + "statuscode":2000, + "shortmessage":"Login successful", + "longmessage":"Session has been created successful.", + "responsedata":"" +} diff --git a/providers/dns/netcup/internal/fixtures/login-request.json b/providers/dns/netcup/internal/fixtures/login-request.json new file mode 100644 index 000000000..1e287dfe0 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/login-request.json @@ -0,0 +1,8 @@ +{ + "action": "login", + "param": { + "customernumber": "a", + "apikey": "b", + "apipassword": "c" + } +} diff --git a/providers/dns/netcup/internal/fixtures/login.json b/providers/dns/netcup/internal/fixtures/login.json new file mode 100644 index 000000000..a66979544 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/login.json @@ -0,0 +1,12 @@ +{ + "serverrequestid": "srv-request-id", + "clientrequestid": "", + "action": "login", + "status": "success", + "statuscode": 2000, + "shortmessage": "Login successful", + "longmessage": "Session has been created successful.", + "responsedata": { + "apisessionid": "api-session-id" + } +} diff --git a/providers/dns/netcup/internal/fixtures/login_error.json b/providers/dns/netcup/internal/fixtures/login_error.json new file mode 100644 index 000000000..a32568f78 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/login_error.json @@ -0,0 +1,10 @@ +{ + "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE", + "clientrequestid":"", + "action":"login", + "status":"error", + "statuscode":4013, + "shortmessage":"Validation Error.", + "longmessage":"Message is empty.", + "responsedata":"" +} diff --git a/providers/dns/netcup/internal/fixtures/login_error_unmarshal.json b/providers/dns/netcup/internal/fixtures/login_error_unmarshal.json new file mode 100644 index 000000000..96e7cbd0c --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/login_error_unmarshal.json @@ -0,0 +1,10 @@ +{ + "serverrequestid": "srv-request-id", + "clientrequestid": "", + "action": "login", + "status": "success", + "statuscode": 2000, + "shortmessage": "Login successful", + "longmessage": "Session has been created successful.", + "responsedata": "" +} diff --git a/providers/dns/netcup/internal/fixtures/logout-request.json b/providers/dns/netcup/internal/fixtures/logout-request.json new file mode 100644 index 000000000..add759c3a --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/logout-request.json @@ -0,0 +1,8 @@ +{ + "action": "logout", + "param": { + "customernumber": "a", + "apikey": "b", + "apisessionid": "session-id" + } +} diff --git a/providers/dns/netcup/internal/fixtures/logout.json b/providers/dns/netcup/internal/fixtures/logout.json new file mode 100644 index 000000000..50881fff3 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/logout.json @@ -0,0 +1,10 @@ +{ + "serverrequestid": "request-id", + "clientrequestid": "", + "action": "logout", + "status": "success", + "statuscode": 2000, + "shortmessage": "Logout successful", + "longmessage": "Session has been terminated successful.", + "responsedata": "" +} diff --git a/providers/dns/netcup/internal/fixtures/logout_error.json b/providers/dns/netcup/internal/fixtures/logout_error.json new file mode 100644 index 000000000..a2de32da1 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/logout_error.json @@ -0,0 +1,10 @@ +{ + "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE", + "clientrequestid":"", + "action":"logout", + "status":"error", + "statuscode":4013, + "shortmessage":"Validation Error.", + "longmessage":"Message is empty.", + "responsedata":"" +} diff --git a/providers/dns/netcup/internal/session.go b/providers/dns/netcup/internal/session.go index 6627d74e1..b53751edf 100644 --- a/providers/dns/netcup/internal/session.go +++ b/providers/dns/netcup/internal/session.go @@ -24,6 +24,7 @@ func (c *Client) login(ctx context.Context) (string, error) { } var responseData LoginResponse + err := c.doRequest(ctx, payload, &responseData) if err != nil { return "", fmt.Errorf("loging error: %w", err) diff --git a/providers/dns/netcup/internal/session_test.go b/providers/dns/netcup/internal/session_test.go index 2b69265d2..7704c2604 100644 --- a/providers/dns/netcup/internal/session_test.go +++ b/providers/dns/netcup/internal/session_test.go @@ -1,59 +1,28 @@ package internal import ( - "bytes" "context" - "fmt" - "io" "net/http" - "strconv" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func mockContext() context.Context { - return context.WithValue(context.Background(), sessionIDKey, "session-id") +func mockContext(t *testing.T) context.Context { + t.Helper() + + return context.WithValue(t.Context(), sessionIDKey, "session-id") } func TestClient_Login(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("login.json"), + servermock.CheckRequestJSONBodyFromFixture("login-request.json")). + Build(t) - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - raw, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if string(bytes.TrimSpace(raw)) != `{"action":"login","param":{"customernumber":"a","apikey":"b","apipassword":"c"}}` { - http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest) - return - } - - response := ` - { - "serverrequestid": "srv-request-id", - "clientrequestid": "", - "action": "login", - "status": "success", - "statuscode": 2000, - "shortmessage": "Login successful", - "longmessage": "Session has been created successful.", - "responsedata": { - "apisessionid": "api-session-id" - } - } - ` - _, err = rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - sessionID, err := client.login(context.Background()) + sessionID, err := client.login(t.Context()) require.NoError(t, err) assert.Equal(t, "api-session-id", sessionID) @@ -61,56 +30,24 @@ func TestClient_Login(t *testing.T) { func TestClient_Login_errors(t *testing.T) { testCases := []struct { - desc string - handler func(rw http.ResponseWriter, req *http.Request) + desc string + handler http.Handler + expected string }{ { - desc: "HTTP error", - handler: func(rw http.ResponseWriter, _ *http.Request) { - http.Error(rw, "error message", http.StatusInternalServerError) - }, + desc: "HTTP error", + handler: servermock.Noop().WithStatusCode(http.StatusInternalServerError), + expected: `loging error: unexpected status code: [status code: 500] body: `, }, { - desc: "API error", - handler: func(rw http.ResponseWriter, _ *http.Request) { - response := ` - { - "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE", - "clientrequestid":"", - "action":"login", - "status":"error", - "statuscode":4013, - "shortmessage":"Validation Error.", - "longmessage":"Message is empty.", - "responsedata":"" - }` - _, err := rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }, + desc: "API error", + handler: servermock.ResponseFromFixture("login_error.json"), + expected: `loging error: an error occurred during the action login: [Status=error, StatusCode=4013, ShortMessage=Validation Error., LongMessage=Message is empty.]`, }, { - desc: "responsedata marshaling error", - handler: func(rw http.ResponseWriter, _ *http.Request) { - response := ` - { - "serverrequestid": "srv-request-id", - "clientrequestid": "", - "action": "login", - "status": "success", - "statuscode": 2000, - "shortmessage": "Login successful", - "longmessage": "Session has been created successful.", - "responsedata": "" - }` - _, err := rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }, + desc: "responsedata marshaling error", + handler: servermock.ResponseFromFixture("login_error_unmarshal.json"), + expected: `loging error: unable to unmarshal response: [status code: 200] body: "" error: json: cannot unmarshal string into Go value of type internal.LoginResponse`, }, } @@ -118,85 +55,40 @@ func TestClient_Login_errors(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client, mux := setupTest(t) + client := mockBuilder(). + Route("POST /", test.handler). + Build(t) - mux.HandleFunc("/", test.handler) - - sessionID, err := client.login(context.Background()) - assert.Error(t, err) - assert.Equal(t, "", sessionID) + sessionID, err := client.login(t.Context()) + assert.EqualError(t, err, test.expected) + assert.Empty(t, sessionID) }) } } func TestClient_Logout(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("logout.json"), + servermock.CheckRequestJSONBodyFromFixture("logout-request.json")). + Build(t) - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - raw, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if string(bytes.TrimSpace(raw)) != `{"action":"logout","param":{"customernumber":"a","apikey":"b","apisessionid":"session-id"}}` { - http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest) - return - } - - response := ` - { - "serverrequestid": "request-id", - "clientrequestid": "", - "action": "logout", - "status": "success", - "statuscode": 2000, - "shortmessage": "Logout successful", - "longmessage": "Session has been terminated successful.", - "responsedata": "" - }` - _, err = rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - err := client.Logout(mockContext()) + err := client.Logout(mockContext(t)) require.NoError(t, err) } func TestClient_Logout_errors(t *testing.T) { testCases := []struct { - desc string - handler func(rw http.ResponseWriter, req *http.Request) + desc string + handler http.Handler + expected string }{ { - desc: "HTTP error", - handler: func(rw http.ResponseWriter, _ *http.Request) { - http.Error(rw, "error message", http.StatusInternalServerError) - }, + desc: "HTTP error", + handler: servermock.Noop().WithStatusCode(http.StatusInternalServerError), }, { - desc: "API error", - handler: func(rw http.ResponseWriter, _ *http.Request) { - response := ` - { - "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE", - "clientrequestid":"", - "action":"logout", - "status":"error", - "statuscode":4013, - "shortmessage":"Validation Error.", - "longmessage":"Message is empty.", - "responsedata":"" - }` - _, err := rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }, + desc: "API error", + handler: servermock.ResponseFromFixture("login_error.json"), }, } @@ -204,39 +96,12 @@ func TestClient_Logout_errors(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client, mux := setupTest(t) + client := mockBuilder(). + Route("POST /", test.handler). + Build(t) - mux.HandleFunc("/", test.handler) - - err := client.Logout(context.Background()) + err := client.Logout(t.Context()) require.Error(t, err) }) } } - -func TestLiveClientAuth(t *testing.T) { - if !envTest.IsLiveTest() { - t.Skip("skipping live test") - } - - // Setup - envTest.RestoreEnv() - - client, err := NewClient( - envTest.GetValue("NETCUP_CUSTOMER_NUMBER"), - envTest.GetValue("NETCUP_API_KEY"), - envTest.GetValue("NETCUP_API_PASSWORD")) - require.NoError(t, err) - - for i := range 4 { - t.Run("Test_"+strconv.Itoa(i+1), func(t *testing.T) { - t.Parallel() - - ctx, err := client.CreateSessionContext(context.Background()) - require.NoError(t, err) - - err = client.Logout(ctx) - require.NoError(t, err) - }) - } -} diff --git a/providers/dns/netcup/internal/types.go b/providers/dns/netcup/internal/types.go index 55212f909..e4cc5ec14 100644 --- a/providers/dns/netcup/internal/types.go +++ b/providers/dns/netcup/internal/types.go @@ -72,7 +72,6 @@ type DNSRecord struct { Destination string `json:"destination"` DeleteRecord bool `json:"deleterecord,omitempty"` State string `json:"state,omitempty"` - TTL int `json:"ttl,omitempty"` } // ResponseMsg as specified in netcup WSDL. diff --git a/providers/dns/netcup/netcup.go b/providers/dns/netcup/netcup.go index a8fc8b172..13b329e07 100644 --- a/providers/dns/netcup/netcup.go +++ b/providers/dns/netcup/netcup.go @@ -9,9 +9,11 @@ import ( "strings" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/netcup/internal" ) @@ -23,29 +25,34 @@ const ( EnvAPIKey = envNamespace + "API_KEY" EnvAPIPassword = envNamespace + "API_PASSWORD" - EnvTTL = envNamespace + "TTL" + // Deprecated: the TTL is not configurable on record. + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Key string Password string Customer string - TTL int PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client + + // Deprecated: the TTL is not configurable on record. + TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 15*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 30*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), }, @@ -86,7 +93,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, fmt.Errorf("netcup: %w", err) } - client.HTTPClient = config.HTTPClient + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{client: client, config: config}, nil } @@ -108,7 +119,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { defer func() { err = d.client.Logout(ctx) if err != nil { - log.Print("netcup: %v", err) + log.Printf("netcup: %v", err) } }() @@ -117,7 +128,6 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { Hostname: hostname, RecordType: "TXT", Destination: info.Value, - TTL: d.config.TTL, } zone = dns01.UnFqdn(zone) @@ -155,7 +165,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { defer func() { err = d.client.Logout(ctx) if err != nil { - log.Print("netcup: %v", err) + log.Printf("netcup: %v", err) } }() diff --git a/providers/dns/netcup/netcup.toml b/providers/dns/netcup/netcup.toml index 0954d07d6..4ef8688c6 100644 --- a/providers/dns/netcup/netcup.toml +++ b/providers/dns/netcup/netcup.toml @@ -8,7 +8,7 @@ Example = ''' NETCUP_CUSTOMER_NUMBER=xxxx \ NETCUP_API_KEY=yyyy \ NETCUP_API_PASSWORD=zzzz \ -lego --email you@example.com --dns netcup -d '*.example.com' -d example.com run +lego --dns netcup -d '*.example.com' -d example.com run ''' [Configuration] @@ -17,10 +17,9 @@ lego --email you@example.com --dns netcup -d '*.example.com' -d example.com run NETCUP_API_KEY = "API key" NETCUP_API_PASSWORD = "API password" [Configuration.Additional] - NETCUP_POLLING_INTERVAL = "Time between DNS propagation check" - NETCUP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - NETCUP_TTL = "The TTL of the TXT record used for the DNS challenge" - NETCUP_HTTP_TIMEOUT = "API request timeout" + NETCUP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)" + NETCUP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 900)" + NETCUP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://www.netcup-wiki.de/wiki/DNS_API" diff --git a/providers/dns/netcup/netcup_test.go b/providers/dns/netcup/netcup_test.go index f9cc43ab9..fedc56ba9 100644 --- a/providers/dns/netcup/netcup_test.go +++ b/providers/dns/netcup/netcup_test.go @@ -72,6 +72,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -158,13 +159,14 @@ func TestLivePresentAndCleanup(t *testing.T) { } envTest.RestoreEnv() + p, err := NewDNSProvider() require.NoError(t, err) info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==") zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) - require.NoError(t, err, "error finding DNSZone") + require.NoError(t, err) zone = dns01.UnFqdn(zone) @@ -181,7 +183,7 @@ func TestLivePresentAndCleanup(t *testing.T) { require.NoError(t, err) err = p.CleanUp(test, "987d", "123d==") - require.NoError(t, err, "Did not clean up! Please remove record yourself.") + require.NoError(t, err) }) } } diff --git a/providers/dns/netlify/internal/client.go b/providers/dns/netlify/internal/client.go index 06651bdec..3b6b681fe 100644 --- a/providers/dns/netlify/internal/client.go +++ b/providers/dns/netlify/internal/client.go @@ -59,6 +59,7 @@ func (c *Client) GetRecords(ctx context.Context, zoneID string) ([]DNSRecord, er } var records []DNSRecord + err = json.Unmarshal(raw, &records) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) @@ -93,6 +94,7 @@ func (c *Client) CreateRecord(ctx context.Context, zoneID string, record DNSReco } var recordResp DNSRecord + err = json.Unmarshal(raw, &recordResp) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) @@ -124,7 +126,7 @@ func (c *Client) RemoveRecord(ctx context.Context, zoneID, recordID string) erro return nil } -func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload interface{}) (*http.Request, error) { +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { diff --git a/providers/dns/netlify/internal/client_test.go b/providers/dns/netlify/internal/client_test.go index e06a579b7..b19a8f071 100644 --- a/providers/dns/netlify/internal/client_test.go +++ b/providers/dns/netlify/internal/client_test.go @@ -1,64 +1,35 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, token string) (*Client, *http.ServeMux) { - t.Helper() +func setupClient(token string) func(server *httptest.Server) (*Client, error) { + return func(server *httptest.Server) (*Client, error) { + client := NewClient(OAuthStaticAccessToken(server.Client(), token)) + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(OAuthStaticAccessToken(server.Client(), token)) - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + return client, nil + } } func TestClient_GetRecords(t *testing.T) { - client, mux := setupTest(t, "tokenA") + client := servermock.NewBuilder[*Client](setupClient("tokenA"), + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer tokenA"), + ). + Route("GET /dns_zones/zoneID/dns_records", + servermock.ResponseFromFixture("get_records.json")). + Build(t) - mux.HandleFunc("/dns_zones/zoneID/dns_records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "unsupported method", http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Bearer tokenA" { - http.Error(rw, fmt.Sprintf("invali token: %s", auth), http.StatusUnauthorized) - return - } - - rw.Header().Set("Content-Type", "application/json; charset=utf-8") - - file, err := os.Open("./fixtures/get_records.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - records, err := client.GetRecords(context.Background(), "zoneID") + records, err := client.GetRecords(t.Context(), "zoneID") require.NoError(t, err) expected := []DNSRecord{ @@ -70,36 +41,16 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_CreateRecord(t *testing.T) { - client, mux := setupTest(t, "tokenB") - - mux.HandleFunc("/dns_zones/zoneID/dns_records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "unsupported method", http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Bearer tokenB" { - http.Error(rw, fmt.Sprintf("invali token: %s", auth), http.StatusUnauthorized) - return - } - - rw.Header().Set("Content-Type", "application/json; charset=utf-8") - rw.WriteHeader(http.StatusCreated) - - file, err := os.Open("./fixtures/create_record.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := servermock.NewBuilder[*Client](setupClient("tokenB"), + servermock.CheckHeader(). + WithAccept("application/json"). + WithContentType("application/json; charset=utf-8"). + WithAuthorization("Bearer tokenB"), + ). + Route("POST /dns_zones/zoneID/dns_records", + servermock.ResponseFromFixture("create_record.json"). + WithStatusCode(http.StatusCreated)). + Build(t) record := DNSRecord{ Hostname: "_acme-challenge.example.com", @@ -108,7 +59,7 @@ func TestClient_CreateRecord(t *testing.T) { Value: "txtxtxtxtxtxt", } - result, err := client.CreateRecord(context.Background(), "zoneID", record) + result, err := client.CreateRecord(t.Context(), "zoneID", record) require.NoError(t, err) expected := &DNSRecord{ @@ -123,23 +74,15 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_RemoveRecord(t *testing.T) { - client, mux := setupTest(t, "tokenC") + client := servermock.NewBuilder[*Client](setupClient("tokenC"), + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer tokenC"), + ). + Route("DELETE /dns_zones/zoneID/dns_records/recordID", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) - mux.HandleFunc("/dns_zones/zoneID/dns_records/recordID", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, "unsupported method", http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Bearer tokenC" { - http.Error(rw, fmt.Sprintf("invali token: %s", auth), http.StatusUnauthorized) - return - } - - rw.WriteHeader(http.StatusNoContent) - }) - - err := client.RemoveRecord(context.Background(), "zoneID", "recordID") + err := client.RemoveRecord(t.Context(), "zoneID", "recordID") require.NoError(t, err) } diff --git a/providers/dns/netlify/netlify.go b/providers/dns/netlify/netlify.go index 1a65e330d..5b2980d24 100644 --- a/providers/dns/netlify/netlify.go +++ b/providers/dns/netlify/netlify.go @@ -10,8 +10,10 @@ import ( "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/netlify/internal" ) @@ -27,6 +29,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Token string @@ -81,7 +85,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("netlify: incomplete credentials, missing token") } - client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.Token)) + client := internal.NewClient( + clientdebug.Wrap( + internal.OAuthStaticAccessToken(config.HTTPClient, config.Token), + ), + ) return &DNSProvider{ config: config, @@ -141,6 +149,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("netlify: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } diff --git a/providers/dns/netlify/netlify.toml b/providers/dns/netlify/netlify.toml index 1191c6beb..9d3c0f6b5 100644 --- a/providers/dns/netlify/netlify.toml +++ b/providers/dns/netlify/netlify.toml @@ -6,17 +6,17 @@ Since = "v3.7.0" Example = ''' NETLIFY_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns netlify -d '*.example.com' -d example.com run +lego --dns netlify -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NETLIFY_TOKEN = "Token" [Configuration.Additional] - NETLIFY_POLLING_INTERVAL = "Time between DNS propagation check" - NETLIFY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - NETLIFY_TTL = "The TTL of the TXT record used for the DNS challenge" - NETLIFY_HTTP_TIMEOUT = "API request timeout" + NETLIFY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + NETLIFY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + NETLIFY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + NETLIFY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://open-api.netlify.com/" diff --git a/providers/dns/netlify/netlify_test.go b/providers/dns/netlify/netlify_test.go index f351802da..1e84517be 100644 --- a/providers/dns/netlify/netlify_test.go +++ b/providers/dns/netlify/netlify_test.go @@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -93,6 +94,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -106,6 +108,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/nicmanager/internal/client.go b/providers/dns/nicmanager/internal/client.go index 3134fc4fd..16bfe497b 100644 --- a/providers/dns/nicmanager/internal/client.go +++ b/providers/dns/nicmanager/internal/client.go @@ -23,7 +23,7 @@ const ( // Modes. const ( ModeAnycast = "anycast" - ModeZone = "zone" + ModeZone = "zones" ) // Options the Client options. @@ -74,7 +74,7 @@ func NewClient(opts Options) *Client { return c } -func (c Client) GetZone(ctx context.Context, name string) (*Zone, error) { +func (c *Client) GetZone(ctx context.Context, name string) (*Zone, error) { endpoint := c.baseURL.JoinPath(c.mode, name) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -83,6 +83,7 @@ func (c Client) GetZone(ctx context.Context, name string) (*Zone, error) { } var zone Zone + err = c.do(req, http.StatusOK, &zone) if err != nil { return nil, err @@ -91,7 +92,7 @@ func (c Client) GetZone(ctx context.Context, name string) (*Zone, error) { return &zone, nil } -func (c Client) AddRecord(ctx context.Context, zone string, payload RecordCreateUpdate) error { +func (c *Client) AddRecord(ctx context.Context, zone string, payload RecordCreateUpdate) error { endpoint := c.baseURL.JoinPath(c.mode, zone, "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload) @@ -107,7 +108,7 @@ func (c Client) AddRecord(ctx context.Context, zone string, payload RecordCreate return nil } -func (c Client) DeleteRecord(ctx context.Context, zone string, record int) error { +func (c *Client) DeleteRecord(ctx context.Context, zone string, record int) error { endpoint := c.baseURL.JoinPath(c.mode, zone, "records", strconv.Itoa(record)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) @@ -123,7 +124,7 @@ func (c Client) DeleteRecord(ctx context.Context, zone string, record int) error return nil } -func (c Client) do(req *http.Request, expectedStatusCode int, result any) error { +func (c *Client) do(req *http.Request, expectedStatusCode int, result any) error { req.SetBasicAuth(c.username, c.password) if c.otp != "" { diff --git a/providers/dns/nicmanager/internal/client_test.go b/providers/dns/nicmanager/internal/client_test.go index 822ec0db2..1eb7d5a36 100644 --- a/providers/dns/nicmanager/internal/client_test.go +++ b/providers/dns/nicmanager/internal/client_test.go @@ -1,24 +1,44 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestClient_GetZone(t *testing.T) { - client := setupTest(t, "/anycast/nicmanager-anycastdns4.net", testHandler(http.MethodGet, http.StatusOK, "zone.json")) +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + opts := Options{ + Login: "l", + Username: "u", + Password: "p", + OTP: "2hsn", + } - zone, err := client.GetZone(context.Background(), "nicmanager-anycastdns4.net") + client := NewClient(opts) + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("l.u", "p"). + WithRegexp(headerTOTPToken, `\d{6}`)) +} + +func TestClient_GetZone(t *testing.T) { + client := mockBuilder(). + Route("GET /anycast/nicmanager-anycastdns4.net", + servermock.ResponseFromFixture("zone.json")). + Build(t) + + zone, err := client.GetZone(t.Context(), "nicmanager-anycastdns4.net") require.NoError(t, err) expected := &Zone{ @@ -39,14 +59,22 @@ func TestClient_GetZone(t *testing.T) { } func TestClient_GetZone_error(t *testing.T) { - client := setupTest(t, "/anycast/foo", testHandler(http.MethodGet, http.StatusNotFound, "error.json")) + client := mockBuilder(). + Route("GET /anycast/foo", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) - _, err := client.GetZone(context.Background(), "foo") - require.Error(t, err) + _, err := client.GetZone(t.Context(), "foo") + require.EqualError(t, err, "404: Not Found") } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "/anycast/zonedomain.tld/records", testHandler(http.MethodPost, http.StatusAccepted, "error.json")) + client := mockBuilder(). + Route("POST /anycast/zonedomain.tld/records", + servermock.Noop(). + WithStatusCode(http.StatusAccepted)). + Build(t) record := RecordCreateUpdate{ Type: "TXT", @@ -55,12 +83,16 @@ func TestClient_AddRecord(t *testing.T) { TTL: 3600, } - err := client.AddRecord(context.Background(), "zonedomain.tld", record) + err := client.AddRecord(t.Context(), "zonedomain.tld", record) require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "/anycast/zonedomain.tld", testHandler(http.MethodPost, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("POST /anycast/zonedomain.tld/records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) record := RecordCreateUpdate{ Type: "TXT", @@ -69,78 +101,28 @@ func TestClient_AddRecord_error(t *testing.T) { TTL: 3600, } - err := client.AddRecord(context.Background(), "zonedomain.tld", record) - require.Error(t, err) + err := client.AddRecord(t.Context(), "zonedomain.tld", record) + require.EqualError(t, err, "401: Not Found") } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "/anycast/zonedomain.tld/records/6", testHandler(http.MethodDelete, http.StatusAccepted, "error.json")) + client := mockBuilder(). + Route("DELETE /anycast/zonedomain.tld/records/6", + servermock.Noop(). + WithStatusCode(http.StatusAccepted)). + Build(t) - err := client.DeleteRecord(context.Background(), "zonedomain.tld", 6) + err := client.DeleteRecord(t.Context(), "zonedomain.tld", 6) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "/anycast/zonedomain.tld/records/6", testHandler(http.MethodDelete, http.StatusNoContent, "")) + client := mockBuilder(). + Route("DELETE /anycast/zonedomain.tld/records/6", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) - err := client.DeleteRecord(context.Background(), "zonedomain.tld", 7) - require.Error(t, err) -} - -func setupTest(t *testing.T, path string, handler http.Handler) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.Handle(path, handler) - - opts := Options{ - Login: "foo", - Username: "bar", - Password: "foo", - OTP: "2hsn", - } - - client := NewClient(opts) - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client -} - -func testHandler(method string, statusCode int, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) - return - } - - username, password, ok := req.BasicAuth() - if !ok || username != "foo.bar" || password != "foo" { - http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized) - return - } - - rw.WriteHeader(statusCode) - - if statusCode == http.StatusNoContent { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - } + err := client.DeleteRecord(t.Context(), "zonedomain.tld", 6) + require.EqualError(t, err, "404: Not Found") } diff --git a/providers/dns/nicmanager/nicmanager.go b/providers/dns/nicmanager/nicmanager.go index 5f7eaff60..9b27df64e 100644 --- a/providers/dns/nicmanager/nicmanager.go +++ b/providers/dns/nicmanager/nicmanager.go @@ -9,8 +9,10 @@ import ( "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/nicmanager/internal" ) @@ -23,7 +25,7 @@ const ( EnvEmail = envNamespace + "API_EMAIL" EnvPassword = envNamespace + "API_PASSWORD" EnvOTP = envNamespace + "API_OTP" - EnvMode = envNamespace + "MODE" + EnvMode = envNamespace + "API_MODE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" @@ -33,6 +35,8 @@ const ( const minTTL = 900 +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Login string @@ -82,7 +86,7 @@ func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() config.Password = values[EnvPassword] - config.Mode = env.GetOrDefaultString(EnvMode, internal.ModeAnycast) + config.Mode = env.GetOneWithFallback(EnvMode, internal.ModeAnycast, env.ParseString, envNamespace+"MODE") config.Username = env.GetOrFile(EnvUsername) config.Login = env.GetOrFile(EnvLogin) config.Email = env.GetOrFile(EnvEmail) @@ -125,6 +129,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{client: client, config: config}, nil } @@ -185,8 +191,11 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { name := dns01.UnFqdn(info.EffectiveFQDN) - var existingRecord internal.Record - var existingRecordFound bool + var ( + existingRecord internal.Record + existingRecordFound bool + ) + for _, record := range zone.Records { if strings.EqualFold(record.Type, "TXT") && strings.EqualFold(record.Name, name) && record.Content == info.Value { existingRecord = record diff --git a/providers/dns/nicmanager/nicmanager.toml b/providers/dns/nicmanager/nicmanager.toml index 7be44deb8..d5921de5a 100644 --- a/providers/dns/nicmanager/nicmanager.toml +++ b/providers/dns/nicmanager/nicmanager.toml @@ -13,7 +13,7 @@ NICMANAGER_API_PASSWORD = "password" \ # Optionally, if your account has TOTP enabled, set the secret here NICMANAGER_API_OTP = "long-secret" \ -lego --email you@example.com --dns nicmanager -d '*.example.com' -d example.com run +lego --dns nicmanager -d '*.example.com' -d example.com run ## Login using account name + username @@ -24,14 +24,14 @@ NICMANAGER_API_PASSWORD = "password" \ # Optionally, if your account has TOTP enabled, set the secret here NICMANAGER_API_OTP = "long-secret" \ -lego --email you@example.com --dns nicmanager -d '*.example.com' -d example.com run +lego --dns nicmanager -d '*.example.com' -d example.com run ''' Additional = ''' ## Description You can log in using your account name + username or using your email address. -Optionally if TOTP is configured for your account, set `NICMANAGER_API_OTP`. +Optionally, if TOTP is configured for your account, set `NICMANAGER_API_OTP`. ''' [Configuration] @@ -42,11 +42,11 @@ Optionally if TOTP is configured for your account, set `NICMANAGER_API_OTP`. NICMANAGER_API_PASSWORD = "Password, always required" [Configuration.Additional] NICMANAGER_API_OTP = "TOTP Secret (optional)" - NICMANAGER_API_MODE = "mode: 'anycast' or 'zone' (default: 'anycast')" - NICMANAGER_POLLING_INTERVAL = "Time between DNS propagation check" - NICMANAGER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - NICMANAGER_TTL = "The TTL of the TXT record used for the DNS challenge" - NICMANAGER_HTTP_TIMEOUT = "API request timeout" + NICMANAGER_API_MODE = "mode: 'anycast' or 'zones' (for FreeDNS) (default: 'anycast')" + NICMANAGER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + NICMANAGER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" + NICMANAGER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 900)" + NICMANAGER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://api.nicmanager.com/docs/v1/" diff --git a/providers/dns/nicmanager/nicmanager_test.go b/providers/dns/nicmanager/nicmanager_test.go index bc2f50cc3..114cdb7ca 100644 --- a/providers/dns/nicmanager/nicmanager_test.go +++ b/providers/dns/nicmanager/nicmanager_test.go @@ -66,6 +66,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -159,6 +160,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -172,6 +174,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/nicru/internal/client.go b/providers/dns/nicru/internal/client.go new file mode 100644 index 000000000..5d851fc76 --- /dev/null +++ b/providers/dns/nicru/internal/client.go @@ -0,0 +1,250 @@ +package internal + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const ( + apiBaseURL = "https://api.nic.ru/dns-master" + tokenURL = "https://api.nic.ru/oauth/token" +) + +const successStatus = "success" + +// Trimmer trim all XML fields. +type Trimmer struct { + decoder *xml.Decoder +} + +func (tr Trimmer) Token() (xml.Token, error) { + t, err := tr.decoder.Token() + if cd, ok := t.(xml.CharData); ok { + t = xml.CharData(bytes.TrimSpace(cd)) + } + + return t, err +} + +type Client struct { + baseURL *url.URL + httpClient *http.Client +} + +func NewClient(httpClient *http.Client) (*Client, error) { + if httpClient == nil { + httpClient = &http.Client{Timeout: 5 * time.Second} + } + + baseURL, _ := url.Parse(apiBaseURL) + + return &Client{ + baseURL: baseURL, + httpClient: httpClient, + }, nil +} + +func (c *Client) GetServices(ctx context.Context) ([]Service, error) { + endpoint := c.baseURL.JoinPath("services") + + req, err := newXMLRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + apiResponse, err := c.do(req) + if err != nil { + return nil, err + } + + if apiResponse.Data == nil { + return nil, nil + } + + return apiResponse.Data.Service, nil +} + +func (c *Client) ListZones(ctx context.Context) ([]Zone, error) { + endpoint := c.baseURL.JoinPath("zones") + + req, err := newXMLRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + apiResponse, err := c.do(req) + if err != nil { + return nil, err + } + + if apiResponse.Data == nil { + return nil, nil + } + + return apiResponse.Data.Zone, nil +} + +func (c *Client) GetZonesByService(ctx context.Context, serviceName string) ([]Zone, error) { + endpoint := c.baseURL.JoinPath("services", serviceName, "zones") + + req, err := newXMLRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + apiResponse, err := c.do(req) + if err != nil { + return nil, err + } + + if apiResponse.Data == nil { + return nil, nil + } + + return apiResponse.Data.Zone, nil +} + +func (c *Client) GetRecords(ctx context.Context, serviceName, zoneName string) ([]RR, error) { + endpoint := c.baseURL.JoinPath("services", serviceName, "zones", zoneName, "records") + + req, err := newXMLRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + apiResponse, err := c.do(req) + if err != nil { + return nil, err + } + + if apiResponse.Data == nil { + return nil, nil + } + + var records []RR + for _, zone := range apiResponse.Data.Zone { + records = append(records, zone.RR...) + } + + return records, nil +} + +func (c *Client) DeleteRecord(ctx context.Context, serviceName, zoneName, id string) error { + endpoint := c.baseURL.JoinPath("services", serviceName, "zones", zoneName, "records", id) + + req, err := newXMLRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + _, err = c.do(req) + if err != nil { + return err + } + + return nil +} + +func (c *Client) CommitZone(ctx context.Context, serviceName, zoneName string) error { + endpoint := c.baseURL.JoinPath("services", serviceName, "zones", zoneName, "commit") + + req, err := newXMLRequest(ctx, http.MethodPost, endpoint, nil) + if err != nil { + return err + } + + _, err = c.do(req) + if err != nil { + return err + } + + return nil +} + +func (c *Client) AddRecords(ctx context.Context, serviceName, zoneName string, rrs []RR) ([]Zone, error) { + endpoint := c.baseURL.JoinPath("services", serviceName, "zones", zoneName, "records") + + payload := &Request{RRList: &RRList{RR: rrs}} + + req, err := newXMLRequest(ctx, http.MethodPut, endpoint, payload) + if err != nil { + return nil, err + } + + apiResponse, err := c.do(req) + if err != nil { + return nil, err + } + + if apiResponse.Data == nil { + return nil, nil + } + + return apiResponse.Data.Zone, nil +} + +func (c *Client) do(req *http.Request) (*Response, error) { + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + apiResponse := &Response{} + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + decoder := xml.NewTokenDecoder(Trimmer{decoder: xml.NewDecoder(bytes.NewReader(raw))}) + + err = decoder.Decode(apiResponse) + if err != nil { + return nil, fmt.Errorf("[status code=%d] decode XML response: %s", resp.StatusCode, string(raw)) + } + + if apiResponse.Status != successStatus { + return nil, fmt.Errorf("[status code=%d] %s: %w", resp.StatusCode, apiResponse.Status, apiResponse.Errors.Error) + } + + return apiResponse, nil +} + +func newXMLRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + body := new(bytes.Buffer) + + if payload != nil { + body.WriteString(xml.Header) + + encoder := xml.NewEncoder(body) + encoder.Indent("", " ") + + err := encoder.Encode(payload) + if err != nil { + return nil, err + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "text/xml") + + if payload != nil { + req.Header.Set("Content-Type", "text/xml") + } + + return req, nil +} diff --git a/providers/dns/nicru/internal/client_test.go b/providers/dns/nicru/internal/client_test.go new file mode 100644 index 000000000..f01300406 --- /dev/null +++ b/providers/dns/nicru/internal/client_test.go @@ -0,0 +1,398 @@ +package internal + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.Client()) + if err != nil { + return nil, err + } + + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader(). + WithAccept("text/xml"), + ) +} + +func TestClient_GetServices(t *testing.T) { + client := mockBuilder(). + Route("GET /services", servermock.ResponseFromFixture("services_GET.xml")). + Build(t) + + zones, err := client.GetServices(t.Context()) + require.NoError(t, err) + + expected := []Service{ + { + Admin: "123/NIC-REG", + DomainsLimit: "12", + DomainsNum: "5", + Enable: "true", + HasPrimary: "false", + Name: "testservice", + Payer: "123/NIC-REG", + Tariff: "Secondary L", + }, + { + Admin: "123/NIC-REG", + DomainsLimit: "150", + DomainsNum: "10", + Enable: "true", + HasPrimary: "true", + Name: "myservice", + Payer: "123/NIC-REG", + Tariff: "DNS-master XXL", + RRLimit: "7500", + RRNum: "1000", + }, + } + + assert.Equal(t, expected, zones) +} + +func TestClient_ListZones(t *testing.T) { + client := mockBuilder(). + Route("GET /zones", servermock.ResponseFromFixture("zones_all_GET.xml")). + Build(t) + + zones, err := client.ListZones(t.Context()) + require.NoError(t, err) + + expected := []Zone{ + { + Admin: "123/NIC-REG", + Enable: "true", + HasChanges: "false", + HasPrimary: "true", + ID: "227645", + IDNName: "тест.рф", + Name: "xn—e1aybc.xn--p1ai", + Payer: "123/NIC-REG", + Service: "myservice", + }, + { + Admin: "123/NIC-REG", + Enable: "true", + HasChanges: "false", + HasPrimary: "true", + ID: "227642", + IDNName: "example.ru", + Name: "example.ru", + Payer: "123/NIC-REG", + Service: "myservice", + }, + { + Admin: "123/NIC-REG", + Enable: "true", + HasChanges: "false", + HasPrimary: "true", + ID: "227643", + IDNName: "test.su", + Name: "test.su", + Payer: "123/NIC-REG", + Service: "myservice", + }, + } + + assert.Equal(t, expected, zones) +} + +func TestClient_ListZones_error(t *testing.T) { + client := mockBuilder(). + Route("GET /zones", servermock.ResponseFromFixture("errors.xml")). + Build(t) + + _, err := client.ListZones(t.Context()) + require.ErrorIs(t, err, Error{ + Text: "Access token expired or not found", + Code: "4097", + }) +} + +func TestClient_GetZonesByService(t *testing.T) { + client := mockBuilder(). + Route("GET /services/test/zones", + servermock.ResponseFromFixture("zones_GET.xml")). + Build(t) + + zones, err := client.GetZonesByService(t.Context(), "test") + require.NoError(t, err) + + expected := []Zone{ + { + Admin: "123/NIC-REG", + Enable: "true", + HasChanges: "false", + HasPrimary: "true", + ID: "227645", + IDNName: "тест.рф", + Name: "xn—e1aybc.xn--p1ai", + Payer: "123/NIC-REG", + Service: "myservice", + }, + { + Admin: "123/NIC-REG", + Enable: "true", + HasChanges: "false", + HasPrimary: "true", + ID: "227642", + IDNName: "example.ru", + Name: "example.ru", + Payer: "123/NIC-REG", + Service: "myservice", + }, + { + Admin: "123/NIC-REG", + Enable: "true", + HasChanges: "false", + HasPrimary: "true", + ID: "227643", + IDNName: "test.su", + Name: "test.su", + Payer: "123/NIC-REG", + Service: "myservice", + }, + } + + assert.Equal(t, expected, zones) +} + +func TestClient_GetZonesByService_error(t *testing.T) { + client := mockBuilder(). + Route("GET /services/test/zones", + servermock.ResponseFromFixture("errors.xml")). + Build(t) + + _, err := client.GetZonesByService(t.Context(), "test") + require.ErrorIs(t, err, Error{ + Text: "Access token expired or not found", + Code: "4097", + }) +} + +func TestClient_GetRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /services/test/zones/example.com./records", + servermock.ResponseFromFixture("records_GET.xml")). + Build(t) + + records, err := client.GetRecords(t.Context(), "test", "example.com.") + require.NoError(t, err) + + expected := []RR{ + { + ID: "210074", + Name: "@", + IDNName: "@", + TTL: "", + Type: "SOA", + SOA: &SOA{ + MName: &MName{ + Name: "ns3-l2.nic.ru.", + IDNName: "ns3-l2.nic.ru.", + }, + RName: &RName{ + Name: "dns.nic.ru.", + IDNName: "dns.nic.ru.", + }, + Serial: "2011112002", + Refresh: "1440", + Retry: "3600", + Expire: "2592000", + Minimum: "600", + }, + }, + { + ID: "210075", + Name: "@", + IDNName: "@", + Type: "NS", + NS: &NS{ + Name: "ns3-l2.nic.ru.", + IDNName: "ns3- l2.nic.ru.", + }, + }, + { + ID: "210076", + Name: "@", + IDNName: "@", + Type: "NS", + NS: &NS{ + Name: "ns4-l2.nic.ru.", + IDNName: "ns4-l2.nic.ru.", + }, + }, + { + ID: "210077", + Name: "@", + IDNName: "@", + Type: "NS", + NS: &NS{ + Name: "ns8-l2.nic.ru.", + IDNName: "ns8- l2.nic.ru.", + }, + }, + } + + assert.Equal(t, expected, records) +} + +func TestClient_GetRecords_error(t *testing.T) { + client := mockBuilder(). + Route("GET /services/test/zones/example.com./records", + servermock.ResponseFromFixture("errors.xml")). + Build(t) + + _, err := client.GetRecords(t.Context(), "test", "example.com.") + require.ErrorIs(t, err, Error{ + Text: "Access token expired or not found", + Code: "4097", + }) +} + +func TestClient_AddRecord(t *testing.T) { + client := mockBuilder(). + Route("PUT /services/test/zones/example.com./records", + servermock.ResponseFromFixture("records_PUT.xml"), + servermock.CheckHeader(). + WithContentType("text/xml")). + Build(t) + + rrs := []RR{ + { + Name: "@", + Type: "NS", + NS: &NS{Name: "ns4-l2.nic.ru."}, + }, + { + Name: "@", + Type: "NS", + NS: &NS{Name: "ns8-l2.nic.ru."}, + }, + } + + response, err := client.AddRecords(t.Context(), "test", "example.com.", rrs) + require.NoError(t, err) + + expected := []Zone{ + { + Admin: "123/NIC-REG", + HasChanges: "true", + ID: "228095", + IDNName: "test.ru", + Name: "test.ru", + Service: "testservice", + RR: []RR{ + { + ID: "210076", + Name: "@", + IDNName: "@", + Type: "NS", + NS: &NS{ + Name: "ns4-l2.nic.ru.", + IDNName: "ns4-l2.nic.ru.", + }, + }, + { + ID: "210077", + Name: "@", + IDNName: "@", + Type: "NS", + NS: &NS{ + Name: "ns8-l2.nic.ru.", + IDNName: "ns8-l2.nic.ru.", + }, + }, + }, + }, + } + + assert.Equal(t, expected, response) +} + +func TestClient_AddRecord_error(t *testing.T) { + client := mockBuilder(). + Route("PUT /services/test/zones/example.com./records", + servermock.ResponseFromFixture("errors.xml"), + servermock.CheckHeader(). + WithContentType("text/xml")). + Build(t) + + rrs := []RR{ + { + Name: "@", + Type: "NS", + NS: &NS{Name: "ns4-l2.nic.ru."}, + }, + { + Name: "@", + Type: "NS", + NS: &NS{Name: "ns8-l2.nic.ru."}, + }, + } + + _, err := client.AddRecords(t.Context(), "test", "example.com.", rrs) + require.ErrorIs(t, err, Error{ + Text: "Access token expired or not found", + Code: "4097", + }) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /services/test/zones/example.com./records/123", + servermock.ResponseFromFixture("record_DELETE.xml")). + Build(t) + + err := client.DeleteRecord(t.Context(), "test", "example.com.", "123") + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /services/test/zones/example.com./records/123", + servermock.ResponseFromFixture("errors.xml")). + Build(t) + + err := client.DeleteRecord(t.Context(), "test", "example.com.", "123") + require.ErrorIs(t, err, Error{ + Text: "Access token expired or not found", + Code: "4097", + }) +} + +func TestClient_CommitZone(t *testing.T) { + client := mockBuilder(). + Route("POST /services/test/zones/example.com./commit", + servermock.ResponseFromFixture("commit_POST.xml")). + Build(t) + + err := client.CommitZone(t.Context(), "test", "example.com.") + require.NoError(t, err) +} + +func TestClient_CommitZone_error(t *testing.T) { + client := mockBuilder(). + Route("POST /services/test/zones/example.com./commit", + servermock.ResponseFromFixture("errors.xml")). + Build(t) + + err := client.CommitZone(t.Context(), "test", "example.com.") + require.ErrorIs(t, err, Error{ + Text: "Access token expired or not found", + Code: "4097", + }) +} diff --git a/providers/dns/nicru/internal/fixtures/commit_POST.xml b/providers/dns/nicru/internal/fixtures/commit_POST.xml new file mode 100644 index 000000000..530a22d16 --- /dev/null +++ b/providers/dns/nicru/internal/fixtures/commit_POST.xml @@ -0,0 +1,4 @@ + + + success + diff --git a/providers/dns/nicru/internal/fixtures/errors.xml b/providers/dns/nicru/internal/fixtures/errors.xml new file mode 100644 index 000000000..961b9a495 --- /dev/null +++ b/providers/dns/nicru/internal/fixtures/errors.xml @@ -0,0 +1,7 @@ + + + fail + + Access token expired or not found + + diff --git a/providers/dns/nicru/internal/fixtures/record_DELETE.xml b/providers/dns/nicru/internal/fixtures/record_DELETE.xml new file mode 100644 index 000000000..530a22d16 --- /dev/null +++ b/providers/dns/nicru/internal/fixtures/record_DELETE.xml @@ -0,0 +1,4 @@ + + + success + diff --git a/providers/dns/nicru/internal/fixtures/records_GET.xml b/providers/dns/nicru/internal/fixtures/records_GET.xml new file mode 100644 index 000000000..a9df348f9 --- /dev/null +++ b/providers/dns/nicru/internal/fixtures/records_GET.xml @@ -0,0 +1,55 @@ + + + success + + + + @ + @ + SOA + + + ns3-l2.nic.ru. + ns3-l2.nic.ru. + + + dns.nic.ru. + dns.nic.ru. + + 2011112002 + 1440 + 3600 + 2592000 + 600 + + + + @ + @ + NS + + ns3-l2.nic.ru. + ns3- l2.nic.ru. + + + + @ + @ + NS + + ns4-l2.nic.ru. + ns4-l2.nic.ru. + + + + @ + @ + NS + + ns8-l2.nic.ru. + ns8- l2.nic.ru. + + + + + diff --git a/providers/dns/nicru/internal/fixtures/records_PUT.xml b/providers/dns/nicru/internal/fixtures/records_PUT.xml new file mode 100644 index 000000000..a3417a8f3 --- /dev/null +++ b/providers/dns/nicru/internal/fixtures/records_PUT.xml @@ -0,0 +1,10 @@ + + + success + + + @@NSns4-l2.nic.ru.ns4-l2.nic.ru. + @@NSns8-l2.nic.ru.ns8-l2.nic.ru. + + + diff --git a/providers/dns/nicru/internal/fixtures/services_GET.xml b/providers/dns/nicru/internal/fixtures/services_GET.xml new file mode 100644 index 000000000..9534b0b34 --- /dev/null +++ b/providers/dns/nicru/internal/fixtures/services_GET.xml @@ -0,0 +1,12 @@ + + + success + + + + + diff --git a/providers/dns/nicru/internal/fixtures/zones_GET.xml b/providers/dns/nicru/internal/fixtures/zones_GET.xml new file mode 100644 index 000000000..efa2da9a2 --- /dev/null +++ b/providers/dns/nicru/internal/fixtures/zones_GET.xml @@ -0,0 +1,12 @@ + + + success + + + + + + diff --git a/providers/dns/nicru/internal/fixtures/zones_all_GET.xml b/providers/dns/nicru/internal/fixtures/zones_all_GET.xml new file mode 100644 index 000000000..efa2da9a2 --- /dev/null +++ b/providers/dns/nicru/internal/fixtures/zones_all_GET.xml @@ -0,0 +1,12 @@ + + + success + + + + + + diff --git a/providers/dns/nicru/internal/identity.go b/providers/dns/nicru/internal/identity.go new file mode 100644 index 000000000..b4281adbe --- /dev/null +++ b/providers/dns/nicru/internal/identity.go @@ -0,0 +1,64 @@ +package internal + +import ( + "context" + "errors" + "fmt" + "net/http" + + "golang.org/x/oauth2" +) + +// OauthConfiguration credentials. +type OauthConfiguration struct { + OAuth2ClientID string + OAuth2SecretID string + Username string + Password string +} + +func (config *OauthConfiguration) Validate() error { + msg := " is missing in credentials information" + + if config.Username == "" { + return errors.New("username" + msg) + } + + if config.Password == "" { + return errors.New("password" + msg) + } + + if config.OAuth2ClientID == "" { + return errors.New("serviceID" + msg) + } + + if config.OAuth2SecretID == "" { + return errors.New("secret" + msg) + } + + return nil +} + +func NewOauthClient(ctx context.Context, config *OauthConfiguration) (*http.Client, error) { + err := config.Validate() + if err != nil { + return nil, err + } + + oauth2Config := oauth2.Config{ + ClientID: config.OAuth2ClientID, + ClientSecret: config.OAuth2SecretID, + Endpoint: oauth2.Endpoint{ + TokenURL: tokenURL, + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{".+:/dns-master/.+"}, + } + + oauth2Token, err := oauth2Config.PasswordCredentialsToken(ctx, config.Username, config.Password) + if err != nil { + return nil, fmt.Errorf("failed to create oauth2 token: %w", err) + } + + return oauth2Config.Client(ctx, oauth2Token), nil +} diff --git a/providers/dns/nicru/internal/types.go b/providers/dns/nicru/internal/types.go new file mode 100644 index 000000000..ad3f8cc9a --- /dev/null +++ b/providers/dns/nicru/internal/types.go @@ -0,0 +1,214 @@ +package internal + +import ( + "encoding/xml" + "fmt" +) + +type Request struct { + XMLName xml.Name `xml:"request"` + Text string `xml:",chardata"` + RRList *RRList `xml:"rr-list"` +} + +type RRList struct { + Text string `xml:",chardata"` + RR []RR `xml:"rr"` +} + +type RR struct { + Text string `xml:",chardata"` + ID string `xml:"id,attr,omitempty"` + Name string `xml:"name"` + IDNName string `xml:"idn-name"` + TTL string `xml:"ttl"` + Type string `xml:"type"` + SOA *SOA `xml:"soa,omitempty"` + A string `xml:"a,omitempty"` + AAAA string `xml:"aaaa,omitempty"` + CName *CName `xml:"cname,omitempty"` + NS *NS `xml:"ns,omitempty"` + MX *MX `xml:"mx,omitempty"` + SRV *SRV `xml:"srv,omitempty"` + PTR *PTR `xml:"ptr,omitempty"` + TXT *TXT `xml:"txt,omitempty"` + DName *DName `xml:"dname,omitempty"` + HInfo *HInfo `xml:"hinfo,omitempty"` + NAPTR *NAPTR `xml:"naptr,omitempty"` + RP *RP `xml:"rp,omitempty"` +} + +type SOA struct { + Text string `xml:",chardata"` + MName *MName `xml:"mname"` + RName *RName `xml:"rname"` + Serial string `xml:"serial"` + Refresh string `xml:"refresh"` + Retry string `xml:"retry"` + Expire string `xml:"expire"` + Minimum string `xml:"minimum"` +} + +type MName struct { + Text string `xml:",chardata"` + Name string `xml:"name"` + IDNName string `xml:"idn-name,omitempty"` +} + +type RName struct { + Text string `xml:",chardata"` + Name string `xml:"name"` + IDNName string `xml:"idn-name,omitempty"` +} + +type NS struct { + Text string `xml:",chardata"` + Name string `xml:"name"` + IDNName string `xml:"idn-name,omitempty"` +} + +type MX struct { + Text string `xml:",chardata"` + Preference string `xml:"preference"` + Exchange *Exchange `xml:"exchange"` +} + +type Exchange struct { + Name string `xml:"name"` +} + +type SRV struct { + Text string `xml:",chardata"` + Priority string `xml:"priority"` + Weight string `xml:"weight"` + Port string `xml:"port"` + Target *Target `xml:"target"` +} + +type Target struct { + Text string `xml:",chardata"` + Name string `xml:"name"` +} + +type PTR struct { + Text string `xml:",chardata"` + Name string `xml:"name"` +} + +type HInfo struct { + Text string `xml:",chardata"` + Hardware string `xml:"hardware"` + OS string `xml:"os"` +} + +type NAPTR struct { + Text string `xml:",chardata"` + Order string `xml:"order"` + Preference string `xml:"preference"` + Flags string `xml:"flags"` + Service string `xml:"service"` + Regexp string `xml:"regexp"` + Replacement *Replacement `xml:"replacement"` +} + +type Replacement struct { + Text string `xml:",chardata"` + Name string `xml:"name"` +} + +type RP struct { + Text string `xml:",chardata"` + MboxDName *MboxDName `xml:"mbox-dname"` + TxtDName *TxtDName `xml:"txt-dname"` +} + +type MboxDName struct { + Text string `xml:",chardata"` + Name string `xml:"name"` +} + +type TxtDName struct { + Text string `xml:",chardata"` + Name string `xml:"name"` +} + +type CName struct { + Text string `xml:",chardata"` + Name string `xml:"name"` + IDNName string `xml:"idn-name,omitempty"` +} + +type DName struct { + Text string `xml:",chardata"` + Name string `xml:"name"` +} + +type TXT struct { + Text string `xml:",chardata"` + String string `xml:"string"` +} + +type Response struct { + XMLName xml.Name `xml:"response"` + Text string `xml:",chardata"` + Status string `xml:"status"` + Data *Data `xml:"data"` + Errors Errors `xml:"errors"` +} + +type Data struct { + Text string `xml:",chardata"` + Service []Service `xml:"service"` + Zone []Zone `xml:"zone"` + Address []string `xml:"address"` + Revision []Revision `xml:"revision"` +} + +type Errors struct { + Text string `xml:",chardata"` + Error Error `xml:"error"` +} + +type Error struct { + Text string `xml:",chardata"` + Code string `xml:"code,attr"` +} + +func (e Error) Error() string { + return fmt.Sprintf("%s (code %s)", e.Text, e.Code) +} + +type Service struct { + Text string `xml:",chardata"` + Admin string `xml:"admin,attr"` + DomainsLimit string `xml:"domains-limit,attr"` + DomainsNum string `xml:"domains-num,attr"` + Enable string `xml:"enable,attr"` + HasPrimary string `xml:"has-primary,attr"` + Name string `xml:"name,attr"` + Payer string `xml:"payer,attr"` + Tariff string `xml:"tariff,attr"` + RRLimit string `xml:"rr-limit,attr"` + RRNum string `xml:"rr-num,attr"` +} + +type Zone struct { + Text string `xml:",chardata"` + Admin string `xml:"admin,attr"` + Enable string `xml:"enable,attr"` + HasChanges string `xml:"has-changes,attr"` + HasPrimary string `xml:"has-primary,attr"` + ID string `xml:"id,attr"` + IDNName string `xml:"idn-name,attr"` + Name string `xml:"name,attr"` + Payer string `xml:"payer,attr"` + Service string `xml:"service,attr"` + RR []RR `xml:"rr"` +} + +type Revision struct { + Text string `xml:",chardata"` + Date string `xml:"date,attr"` + IP string `xml:"ip,attr"` + Number string `xml:"number,attr"` +} diff --git a/providers/dns/nicru/nicru.go b/providers/dns/nicru/nicru.go new file mode 100644 index 000000000..cf4255bdb --- /dev/null +++ b/providers/dns/nicru/nicru.go @@ -0,0 +1,239 @@ +// Package nicru implements a DNS provider for solving the DNS-01 challenge using RU Center. +package nicru + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/nicru/internal" +) + +// Environment variables names. +const ( + envNamespace = "NICRU_" + + EnvUsername = envNamespace + "USER" + EnvPassword = envNamespace + "PASSWORD" + EnvServiceID = envNamespace + "SERVICE_ID" + EnvSecret = envNamespace + "SECRET" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + TTL int + Username string + Password string + ServiceID string + Secret string + PropagationTimeout time.Duration + PollingInterval time.Duration +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, 30), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 1*time.Minute), + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + client *internal.Client + config *Config +} + +// NewDNSProvider returns a DNSProvider instance configured for RU Center. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvPassword, EnvServiceID, EnvSecret) + if err != nil { + return nil, fmt.Errorf("nicru: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + config.ServiceID = values[EnvServiceID] + config.Secret = values[EnvSecret] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for RU Center. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("nicru: the configuration of the DNS provider is nil") + } + + clientCfg := &internal.OauthConfiguration{ + OAuth2ClientID: config.ServiceID, + OAuth2SecretID: config.Secret, + Username: config.Username, + Password: config.Password, + } + + oauthClient, err := internal.NewOauthClient(context.Background(), clientCfg) + if err != nil { + return nil, fmt.Errorf("nicru: %w", err) + } + + client, err := internal.NewClient(clientdebug.Wrap(oauthClient)) + if err != nil { + return nil, fmt.Errorf("nicru: unable to build API client: %w", err) + } + + return &DNSProvider{ + client: client, + config: config, + }, nil +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, _, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("nicru: could not find zone for domain %q: %w", domain, err) + } + + authZone = dns01.UnFqdn(authZone) + + zone, err := d.findZone(ctx, authZone) + if err != nil { + return fmt.Errorf("nicru: find zone: %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("nicru: %w", err) + } + + records, err := d.client.GetRecords(ctx, zone.Service, authZone) + if err != nil { + return fmt.Errorf("nicru: get records: %w", err) + } + + for _, record := range records { + if record.TXT == nil { + continue + } + + if record.TXT.Text == subDomain && record.TXT.String == info.Value { + return nil + } + } + + rrs := []internal.RR{{ + Name: subDomain, + TTL: strconv.Itoa(d.config.TTL), + Type: "TXT", + TXT: &internal.TXT{String: info.Value}, + }} + + _, err = d.client.AddRecords(ctx, zone.Service, authZone, rrs) + if err != nil { + return fmt.Errorf("nicru: add records: %w", err) + } + + err = d.client.CommitZone(ctx, zone.Service, authZone) + if err != nil { + return fmt.Errorf("nicru: commit zone: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("nicru: could not find zone for domain %q: %w", domain, err) + } + + authZone = dns01.UnFqdn(authZone) + + zone, err := d.findZone(ctx, authZone) + if err != nil { + return fmt.Errorf("nicru: find zone: %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("nicru: %w", err) + } + + records, err := d.client.GetRecords(ctx, zone.Service, authZone) + if err != nil { + return fmt.Errorf("nicru: get records: %w", err) + } + + subDomain = dns01.UnFqdn(subDomain) + + for _, record := range records { + if record.TXT == nil { + continue + } + + if record.Name != subDomain || record.TXT.String != info.Value { + continue + } + + err = d.client.DeleteRecord(ctx, zone.Service, authZone, record.ID) + if err != nil { + return fmt.Errorf("nicru: delete record: %w", err) + } + } + + err = d.client.CommitZone(ctx, zone.Service, authZone) + if err != nil { + return fmt.Errorf("nicru: commit zone: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) findZone(ctx context.Context, authZone string) (*internal.Zone, error) { + zones, err := d.client.ListZones(ctx) + if err != nil { + return nil, fmt.Errorf("unable to fetch dns zones: %w", err) + } + + if len(zones) == 0 { + return nil, errors.New("no zones found") + } + + for _, zone := range zones { + if zone.Name == authZone { + return &zone, nil + } + } + + return nil, fmt.Errorf("zone not found for %s", authZone) +} diff --git a/providers/dns/nicru/nicru.toml b/providers/dns/nicru/nicru.toml new file mode 100644 index 000000000..f955511a2 --- /dev/null +++ b/providers/dns/nicru/nicru.toml @@ -0,0 +1,41 @@ +Name = "RU CENTER" +Description = '''''' +URL = "https://nic.ru/" +Code = "nicru" +Since = "v4.24.0" + +Example = ''' +NICRU_USER="" \ +NICRU_PASSWORD="" \ +NICRU_SERVICE_ID="" \ +NICRU_SECRET="" \ +lego --dns nicru -d '*.example.com' -d example.com run +''' + +Additional = ''' +## Credential information + +You can find information about service ID and secret https://www.nic.ru/manager/oauth.cgi?step=oauth.app_list + +| ENV Variable | Parameter from page | Example | +|---------------------|--------------------------------|-------------------| +| NICRU_USER | Username (Number of agreement) | NNNNNNN/NIC-D | +| NICRU_PASSWORD | Password account | | +| NICRU_SERVICE_ID | Application ID | hex-based, len 32 | +| NICRU_SECRET | Identity endpoint | string len 91 | +''' + +[Configuration] + [Configuration.Credentials] + NICRU_USER = "Agreement for an account in RU CENTER" + NICRU_PASSWORD = "Password for an account in RU CENTER" + NICRU_SERVICE_ID = "Service ID for application in DNS-hosting RU CENTER" + NICRU_SECRET = "Secret for application in DNS-hosting RU CENTER" + NICRU_SERVICE_NAME = "Service Name for DNS-hosting RU CENTER" + [Configuration.Additional] + NICRU_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 60)" + NICRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)" + NICRU_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)" + +[Links] + API = "https://www.nic.ru/help/api-dns-hostinga_3643.html" diff --git a/providers/dns/nicru/nicru_test.go b/providers/dns/nicru/nicru_test.go new file mode 100644 index 000000000..7e71f9d2c --- /dev/null +++ b/providers/dns/nicru/nicru_test.go @@ -0,0 +1,195 @@ +package nicru + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const ( + fakeServiceID = "2519234972459cdfa23423adf143324f" + fakeSecret = "oo5ahrie0aiPho3Vee4siupoPhahdahCh1thiesohru" + fakeUsername = "1234567/NIC-D" + fakePassword = "einge8Goo2eBaiXievuj" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvUsername, EnvPassword, EnvServiceID, EnvSecret).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvServiceID: fakeServiceID, + EnvSecret: fakeSecret, + EnvUsername: fakeUsername, + EnvPassword: fakePassword, + }, + expected: "nicru: failed to create oauth2 token: oauth2: \"unauthorized_client\"", + }, + { + desc: "missing serviceID", + envVars: map[string]string{ + EnvSecret: fakeSecret, + EnvUsername: fakeUsername, + EnvPassword: fakePassword, + }, + expected: "nicru: some credentials information are missing: NICRU_SERVICE_ID", + }, + { + desc: "missing secret", + envVars: map[string]string{ + EnvServiceID: fakeServiceID, + EnvUsername: fakeUsername, + EnvPassword: fakePassword, + }, + expected: "nicru: some credentials information are missing: NICRU_SECRET", + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvServiceID: fakeServiceID, + EnvSecret: fakeSecret, + EnvPassword: fakePassword, + }, + expected: "nicru: some credentials information are missing: NICRU_USER", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvServiceID: fakeServiceID, + EnvSecret: fakeSecret, + EnvUsername: fakeUsername, + }, + expected: "nicru: some credentials information are missing: NICRU_PASSWORD", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + config *Config + expected string + }{ + { + desc: "success", + config: &Config{ + ServiceID: fakeServiceID, + Secret: fakeSecret, + Username: fakeUsername, + Password: fakePassword, + }, + expected: "nicru: failed to create oauth2 token: oauth2: \"unauthorized_client\"", + }, + { + desc: "nil config", + config: nil, + expected: "nicru: the configuration of the DNS provider is nil", + }, + { + desc: "missing username", + config: &Config{ + ServiceID: fakeServiceID, + Password: fakePassword, + }, + expected: "nicru: username is missing in credentials information", + }, + { + desc: "missing password", + config: &Config{ + ServiceID: fakeServiceID, + Secret: fakeSecret, + Username: fakeUsername, + }, + expected: "nicru: password is missing in credentials information", + }, + { + desc: "missing secret", + config: &Config{ + ServiceID: fakeServiceID, + Username: fakeUsername, + Password: fakePassword, + }, + expected: "nicru: secret is missing in credentials information", + }, + { + desc: "missing serviceID", + config: &Config{ + Secret: fakeSecret, + Username: fakeUsername, + Password: fakePassword, + }, + expected: "nicru: serviceID is missing in credentials information", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + p, err := NewDNSProviderConfig(test.config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/nifcloud/internal/client.go b/providers/dns/nifcloud/internal/client.go index 4469a1f78..0f3851883 100644 --- a/providers/dns/nifcloud/internal/client.go +++ b/providers/dns/nifcloud/internal/client.go @@ -59,6 +59,7 @@ func (c *Client) ChangeResourceRecordSets(ctx context.Context, hostedZoneID stri } output := &ChangeResourceRecordSetsResponse{} + err = c.do(req, output) if err != nil { return nil, err @@ -77,6 +78,7 @@ func (c *Client) GetChange(ctx context.Context, statusID string) (*GetChangeResp } output := &GetChangeResponse{} + err = c.do(req, output) if err != nil { return nil, err @@ -129,6 +131,7 @@ func (c *Client) sign(req *http.Request) error { } mac := hmac.New(sha1.New, []byte(c.secretKey)) + _, err := mac.Write([]byte(req.Header.Get("Date"))) if err != nil { return err @@ -148,6 +151,7 @@ func newXMLRequest(ctx context.Context, method string, endpoint *url.URL, payloa if payload != nil { body.WriteString(xml.Header) + err := xml.NewEncoder(body).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request XML body: %w", err) @@ -170,6 +174,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errResp := &ErrorResponse{} + err := xml.Unmarshal(raw, errResp) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/nifcloud/internal/client_test.go b/providers/dns/nifcloud/internal/client_test.go index 06c4921e0..501265ada 100644 --- a/providers/dns/nifcloud/internal/client_test.go +++ b/providers/dns/nifcloud/internal/client_test.go @@ -1,38 +1,35 @@ package internal import ( - "context" - "fmt" "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, responseBody string, statusCode int) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("A", "B") + if err != nil { + return nil, err + } - handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(statusCode) - _, _ = fmt.Fprintln(w, responseBody) - }) + client.HTTPClient = server.Client() + client.BaseURL, _ = url.Parse(server.URL) - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - client, err := NewClient("A", "B") - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.BaseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader(). + WithRegexp("X-Nifty-Authorization", "NIFTY3-HTTPS NiftyAccessKeyId=A,Algorithm=HmacSHA1,Signature=.+"), + ) } -func TestChangeResourceRecordSets(t *testing.T) { +func TestClient_ChangeResourceRecordSets(t *testing.T) { responseBody := ` @@ -43,9 +40,12 @@ func TestChangeResourceRecordSets(t *testing.T) { ` - client := setupTest(t, responseBody, http.StatusOK) + client := mockBuilder(). + Route("POST /", servermock.RawStringResponse(responseBody), + servermock.CheckHeader().WithContentType("text/xml; charset=utf-8")). + Build(t) - res, err := client.ChangeResourceRecordSets(context.Background(), "example.com", ChangeResourceRecordSetsRequest{}) + res, err := client.ChangeResourceRecordSets(t.Context(), "example.com", ChangeResourceRecordSetsRequest{}) require.NoError(t, err) assert.Equal(t, "xxxxx", res.ChangeInfo.ID) @@ -53,7 +53,7 @@ func TestChangeResourceRecordSets(t *testing.T) { assert.Equal(t, "2015-08-05T00:00:00.000Z", res.ChangeInfo.SubmittedAt) } -func TestChangeResourceRecordSetsErrors(t *testing.T) { +func TestClient_ChangeResourceRecordSets_errors(t *testing.T) { testCases := []struct { desc string responseBody string @@ -90,16 +90,22 @@ func TestChangeResourceRecordSetsErrors(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := setupTest(t, test.responseBody, test.statusCode) + client := mockBuilder(). + Route("POST /", + servermock.RawStringResponse(test.responseBody). + WithStatusCode(test.statusCode), + servermock.CheckHeader(). + WithContentType("text/xml; charset=utf-8")). + Build(t) - res, err := client.ChangeResourceRecordSets(context.Background(), "example.com", ChangeResourceRecordSetsRequest{}) + res, err := client.ChangeResourceRecordSets(t.Context(), "example.com", ChangeResourceRecordSetsRequest{}) assert.Nil(t, res) assert.EqualError(t, err, test.expected) }) } } -func TestGetChange(t *testing.T) { +func TestClient_GetChange(t *testing.T) { responseBody := ` @@ -110,9 +116,11 @@ func TestGetChange(t *testing.T) { ` - client := setupTest(t, responseBody, http.StatusOK) + client := mockBuilder(). + Route("GET /", servermock.RawStringResponse(responseBody)). + Build(t) - res, err := client.GetChange(context.Background(), "12345") + res, err := client.GetChange(t.Context(), "12345") require.NoError(t, err) assert.Equal(t, "xxxxx", res.ChangeInfo.ID) @@ -120,7 +128,7 @@ func TestGetChange(t *testing.T) { assert.Equal(t, "2015-08-05T00:00:00.000Z", res.ChangeInfo.SubmittedAt) } -func TestGetChangeErrors(t *testing.T) { +func TestClient_GetChange_errors(t *testing.T) { testCases := []struct { desc string responseBody string @@ -157,9 +165,12 @@ func TestGetChangeErrors(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := setupTest(t, test.responseBody, test.statusCode) + client := mockBuilder(). + Route("GET /", + servermock.RawStringResponse(test.responseBody).WithStatusCode(test.statusCode)). + Build(t) - res, err := client.GetChange(context.Background(), "12345") + res, err := client.GetChange(t.Context(), "12345") assert.Nil(t, res) assert.EqualError(t, err, test.expected) }) diff --git a/providers/dns/nifcloud/nifcloud.go b/providers/dns/nifcloud/nifcloud.go index b059e562e..ced7eff09 100644 --- a/providers/dns/nifcloud/nifcloud.go +++ b/providers/dns/nifcloud/nifcloud.go @@ -9,9 +9,12 @@ import ( "net/url" "time" + "github.com/cenkalti/backoff/v5" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/wait" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/nifcloud/internal" ) @@ -29,6 +32,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string @@ -90,6 +95,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + if config.BaseURL != "" { baseURL, err := url.Parse(config.BaseURL) if err != nil { @@ -104,23 +111,29 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) - err := d.changeRecord("CREATE", info.EffectiveFQDN, info.Value, d.config.TTL) + err := d.changeRecord(ctx, "CREATE", info.EffectiveFQDN, info.Value, d.config.TTL) if err != nil { return fmt.Errorf("nifcloud: %w", err) } + return err } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) - err := d.changeRecord("DELETE", info.EffectiveFQDN, info.Value, d.config.TTL) + err := d.changeRecord(ctx, "DELETE", info.EffectiveFQDN, info.Value, d.config.TTL) if err != nil { return fmt.Errorf("nifcloud: %w", err) } + return err } @@ -130,7 +143,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } -func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { +func (d *DNSProvider) changeRecord(ctx context.Context, action, fqdn, value string, ttl int) error { authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return fmt.Errorf("could not find zone: %w", err) @@ -167,8 +180,6 @@ func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { }, } - ctx := context.Background() - resp, err := d.client.ChangeResourceRecordSets(ctx, dns01.UnFqdn(authZone), reqParams) if err != nil { return fmt.Errorf("failed to change record set: %w", err) @@ -176,11 +187,20 @@ func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { statusID := resp.ChangeInfo.ID - return wait.For("nifcloud", 120*time.Second, 4*time.Second, func() (bool, error) { - resp, err := d.client.GetChange(ctx, statusID) - if err != nil { - return false, fmt.Errorf("failed to query change status: %w", err) - } - return resp.ChangeInfo.Status == "INSYNC", nil - }) + return wait.Retry(ctx, + func() error { + resp, err := d.client.GetChange(ctx, statusID) + if err != nil { + return fmt.Errorf("get change: %w", err) + } + + if resp.ChangeInfo.Status != "INSYNC" { + return fmt.Errorf("change status: %s", resp.ChangeInfo.Status) + } + + return nil + }, + backoff.WithBackOff(backoff.NewConstantBackOff(4*time.Second)), + backoff.WithMaxElapsedTime(120*time.Second), + ) } diff --git a/providers/dns/nifcloud/nifcloud.toml b/providers/dns/nifcloud/nifcloud.toml index 9966ce882..3c43b1dc0 100644 --- a/providers/dns/nifcloud/nifcloud.toml +++ b/providers/dns/nifcloud/nifcloud.toml @@ -7,7 +7,7 @@ Since = "v1.1.0" Example = ''' NIFCLOUD_ACCESS_KEY_ID=xxxx \ NIFCLOUD_SECRET_ACCESS_KEY=yyyy \ -lego --email you@example.com --dns nifcloud -d '*.example.com' -d example.com run +lego --dns nifcloud -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,10 +15,10 @@ lego --email you@example.com --dns nifcloud -d '*.example.com' -d example.com ru NIFCLOUD_ACCESS_KEY_ID = "Access key" NIFCLOUD_SECRET_ACCESS_KEY = "Secret access key" [Configuration.Additional] - NIFCLOUD_POLLING_INTERVAL = "Time between DNS propagation check" - NIFCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - NIFCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge" - NIFCLOUD_HTTP_TIMEOUT = "API request timeout" + NIFCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + NIFCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + NIFCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + NIFCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://mbaas.nifcloud.com/doc/current/rest/common/format.html" diff --git a/providers/dns/nifcloud/nifcloud_test.go b/providers/dns/nifcloud/nifcloud_test.go index 9b635edfc..0eff98a71 100644 --- a/providers/dns/nifcloud/nifcloud_test.go +++ b/providers/dns/nifcloud/nifcloud_test.go @@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -129,6 +130,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -142,6 +144,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/njalla/internal/client.go b/providers/dns/njalla/internal/client.go index f7e0023ae..d2893253f 100644 --- a/providers/dns/njalla/internal/client.go +++ b/providers/dns/njalla/internal/client.go @@ -46,6 +46,7 @@ func (c *Client) AddRecord(ctx context.Context, record Record) (*Record, error) } var result APIResponse[*Record] + err = c.do(req, &result) if err != nil { return nil, err @@ -55,7 +56,7 @@ func (c *Client) AddRecord(ctx context.Context, record Record) (*Record, error) } // RemoveRecord removes a record. -func (c *Client) RemoveRecord(ctx context.Context, id string, domain string) error { +func (c *Client) RemoveRecord(ctx context.Context, id, domain string) error { data := APIRequest{ Method: "remove-record", Params: Record{ @@ -92,6 +93,7 @@ func (c *Client) ListRecords(ctx context.Context, domain string) ([]Record, erro } var result APIResponse[Records] + err = c.do(req, &result) if err != nil { return nil, err @@ -127,7 +129,7 @@ func (c *Client) do(req *http.Request, result Response) error { return result.GetError() } -func newJSONRequest(ctx context.Context, method string, endpoint string, payload any) (*http.Request, error) { +func newJSONRequest(ctx context.Context, method, endpoint string, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { diff --git a/providers/dns/njalla/internal/client_test.go b/providers/dns/njalla/internal/client_test.go index 3f173db62..a7e60aefd 100644 --- a/providers/dns/njalla/internal/client_test.go +++ b/providers/dns/njalla/internal/client_test.go @@ -1,76 +1,31 @@ package internal import ( - "context" - "encoding/json" - "fmt" - "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, handler func(http.ResponseWriter, *http.Request)) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - token := req.Header.Get(authorizationHeader) - if token != "Njalla secret" { - _, _ = rw.Write([]byte(`{"jsonrpc":"2.0", "Error": {"code": 403, "message": "Invalid token."}}`)) - return - } - - if handler != nil { - handler(rw, req) - } else { - _, _ = rw.Write([]byte(`{"jsonrpc":"2.0"}`)) - } - }) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.apiEndpoint = server.URL + client.HTTPClient = server.Client() - return client + return client, nil } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { - apiReq := struct { - Method string `json:"method"` - Params Record `json:"params"` - }{} - - err := json.NewDecoder(req.Body).Decode(&apiReq) - if err != nil { - http.Error(rw, "failed to marshal test request body", http.StatusInternalServerError) - return - } - - apiReq.Params.ID = "123" - - resp := map[string]interface{}{ - "jsonrpc": "2.0", - "id": "897", - "result": apiReq.Params, - } - - err = json.NewEncoder(rw).Encode(resp) - if err != nil { - http.Error(rw, "failed to marshal test response", http.StatusInternalServerError) - return - } - }) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Njalla secret"), + ). + Route("POST /", + servermock.ResponseFromFixture("add_record.json"), + servermock.CheckRequestJSONBodyFromFixture("add_record-request.json")). + Build(t) record := Record{ Content: "foobar", @@ -80,7 +35,7 @@ func TestClient_AddRecord(t *testing.T) { Type: "TXT", } - result, err := client.AddRecord(context.Background(), record) + result, err := client.AddRecord(t.Context(), record) require.NoError(t, err) expected := &Record{ @@ -95,7 +50,13 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, nil) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Njalla invalid"), + ). + Route("POST /", servermock.ResponseFromFixture("auth_error.json")). + Build(t) + client.token = "invalid" record := Record{ @@ -106,58 +67,23 @@ func TestClient_AddRecord_error(t *testing.T) { Type: "TXT", } - result, err := client.AddRecord(context.Background(), record) - require.Error(t, err) + result, err := client.AddRecord(t.Context(), record) + require.EqualError(t, err, "code: 403, message: Invalid token.") assert.Nil(t, result) } func TestClient_ListRecords(t *testing.T) { - client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { - apiReq := struct { - Method string `json:"method"` - Params Record `json:"params"` - }{} + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Njalla secret"), + ). + Route("POST /", + servermock.ResponseFromFixture("list_records.json"), + servermock.CheckRequestJSONBodyFromFixture("list_records-request.json")). + Build(t) - err := json.NewDecoder(req.Body).Decode(&apiReq) - if err != nil { - http.Error(rw, "failed to marshal test request body", http.StatusInternalServerError) - return - } - - resp := map[string]interface{}{ - "jsonrpc": "2.0", - "id": "897", - "result": Records{ - Records: []Record{ - { - ID: "1", - Domain: apiReq.Params.Domain, - Content: "test", - Name: "test01", - TTL: 300, - Type: "TXT", - }, - { - ID: "2", - Domain: apiReq.Params.Domain, - Content: "txtTxt", - Name: "test02", - TTL: 120, - Type: "TXT", - }, - }, - }, - } - - err = json.NewEncoder(rw).Encode(resp) - if err != nil { - http.Error(rw, "failed to marshal test response", http.StatusInternalServerError) - return - } - }) - - records, err := client.ListRecords(context.Background(), "example.com") + records, err := client.ListRecords(t.Context(), "example.com") require.NoError(t, err) expected := []Record{ @@ -183,49 +109,43 @@ func TestClient_ListRecords(t *testing.T) { } func TestClient_ListRecords_error(t *testing.T) { - client := setupTest(t, nil) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Njalla invalid"), + ). + Route("POST /", servermock.ResponseFromFixture("auth_error.json")). + Build(t) + client.token = "invalid" - records, err := client.ListRecords(context.Background(), "example.com") - require.Error(t, err) + records, err := client.ListRecords(t.Context(), "example.com") + require.EqualError(t, err, "code: 403, message: Invalid token.") assert.Empty(t, records) } func TestClient_RemoveRecord(t *testing.T) { - client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { - apiReq := struct { - Method string `json:"method"` - Params Record `json:"params"` - }{} + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Njalla secret"), + ). + Route("POST /", + servermock.RawStringResponse(`{"jsonrpc":"2.0"}`), + servermock.CheckRequestJSONBodyFromFixture("remove_record-request.json")). + Build(t) - err := json.NewDecoder(req.Body).Decode(&apiReq) - if err != nil { - http.Error(rw, "failed to marshal test request body", http.StatusInternalServerError) - return - } - - if apiReq.Params.ID == "" { - _, _ = rw.Write([]byte(`{"jsonrpc":"2.0", "Error": {"code": 400, "message": ""missing ID"}}`)) - return - } - - if apiReq.Params.Domain == "" { - _, _ = rw.Write([]byte(`{"jsonrpc":"2.0", "Error": {"code": 400, "message": ""missing domain"}}`)) - return - } - - _, _ = rw.Write([]byte(`{"jsonrpc":"2.0"}`)) - }) - - err := client.RemoveRecord(context.Background(), "123", "example.com") + err := client.RemoveRecord(t.Context(), "123", "example.com") require.NoError(t, err) } func TestClient_RemoveRecord_error(t *testing.T) { - client := setupTest(t, nil) - client.token = "invalid" + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Njalla secret"), + ). + Route("POST /", servermock.ResponseFromFixture("remove_record_error_missing_domain.json")). + Build(t) - err := client.RemoveRecord(context.Background(), "123", "example.com") - require.Error(t, err) + err := client.RemoveRecord(t.Context(), "123", "example.com") + require.EqualError(t, err, "code: 400, message: missing domain") } diff --git a/providers/dns/njalla/internal/fixtures/add_record-request.json b/providers/dns/njalla/internal/fixtures/add_record-request.json new file mode 100644 index 000000000..a85e1aaf1 --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/add_record-request.json @@ -0,0 +1,10 @@ +{ + "method": "add-record", + "params": { + "content": "foobar", + "domain": "test", + "name": "example.com", + "ttl": 300, + "type": "TXT" + } +} diff --git a/providers/dns/njalla/internal/fixtures/add_record.json b/providers/dns/njalla/internal/fixtures/add_record.json new file mode 100644 index 000000000..a537762bf --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/add_record.json @@ -0,0 +1,12 @@ +{ + "id": "897", + "jsonrpc": "2.0", + "result": { + "id": "123", + "content": "foobar", + "domain": "test", + "name": "example.com", + "ttl": 300, + "type": "TXT" + } +} diff --git a/providers/dns/njalla/internal/fixtures/auth_error.json b/providers/dns/njalla/internal/fixtures/auth_error.json new file mode 100644 index 000000000..e9d07be51 --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/auth_error.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "Error": { + "code": 403, + "message": "Invalid token." + } +} diff --git a/providers/dns/njalla/internal/fixtures/list_records-request.json b/providers/dns/njalla/internal/fixtures/list_records-request.json new file mode 100644 index 000000000..ebe5ccf72 --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/list_records-request.json @@ -0,0 +1,6 @@ +{ + "method": "list-records", + "params": { + "domain": "example.com" + } +} diff --git a/providers/dns/njalla/internal/fixtures/list_records.json b/providers/dns/njalla/internal/fixtures/list_records.json new file mode 100644 index 000000000..a280a4b3f --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/list_records.json @@ -0,0 +1,24 @@ +{ + "id": "897", + "jsonrpc": "2.0", + "result": { + "records": [ + { + "id": "1", + "content": "test", + "domain": "example.com", + "name": "test01", + "ttl": 300, + "type": "TXT" + }, + { + "id": "2", + "content": "txtTxt", + "domain": "example.com", + "name": "test02", + "ttl": 120, + "type": "TXT" + } + ] + } +} diff --git a/providers/dns/njalla/internal/fixtures/remove_record-request.json b/providers/dns/njalla/internal/fixtures/remove_record-request.json new file mode 100644 index 000000000..c96e94423 --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/remove_record-request.json @@ -0,0 +1,7 @@ +{ + "method": "remove-record", + "params": { + "id": "123", + "domain": "example.com" + } +} diff --git a/providers/dns/njalla/internal/fixtures/remove_record_error_missing_domain.json b/providers/dns/njalla/internal/fixtures/remove_record_error_missing_domain.json new file mode 100644 index 000000000..f65d254d0 --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/remove_record_error_missing_domain.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "Error": { + "code": 400, + "message": "missing domain" + } +} diff --git a/providers/dns/njalla/internal/fixtures/remove_record_error_missing_id.json b/providers/dns/njalla/internal/fixtures/remove_record_error_missing_id.json new file mode 100644 index 000000000..544cd4d1c --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/remove_record_error_missing_id.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "Error": { + "code": 400, + "message": "missing ID" + } +} diff --git a/providers/dns/njalla/njalla.go b/providers/dns/njalla/njalla.go index fe23e8d6d..2f9aef8ea 100644 --- a/providers/dns/njalla/njalla.go +++ b/providers/dns/njalla/njalla.go @@ -9,8 +9,10 @@ import ( "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/njalla/internal" "github.com/miekg/dns" ) @@ -27,6 +29,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Token string @@ -87,6 +91,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -142,6 +148,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("njalla: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } diff --git a/providers/dns/njalla/njalla.toml b/providers/dns/njalla/njalla.toml index a7e46c02d..ff4750b7d 100644 --- a/providers/dns/njalla/njalla.toml +++ b/providers/dns/njalla/njalla.toml @@ -6,17 +6,17 @@ Since = "v4.3.0" Example = ''' NJALLA_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns njalla -d '*.example.com' -d example.com run +lego --dns njalla -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NJALLA_TOKEN = "API token" [Configuration.Additional] - NJALLA_POLLING_INTERVAL = "Time between DNS propagation check" - NJALLA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - NJALLA_TTL = "The TTL of the TXT record used for the DNS challenge" - NJALLA_HTTP_TIMEOUT = "API request timeout" + NJALLA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + NJALLA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + NJALLA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + NJALLA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://njal.la/api/" diff --git a/providers/dns/njalla/njalla_test.go b/providers/dns/njalla/njalla_test.go index f1489257b..61f106d75 100644 --- a/providers/dns/njalla/njalla_test.go +++ b/providers/dns/njalla/njalla_test.go @@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -95,6 +96,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -108,6 +110,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/nodion/nodion.go b/providers/dns/nodion/nodion.go index e1ce72e81..4bc887568 100644 --- a/providers/dns/nodion/nodion.go +++ b/providers/dns/nodion/nodion.go @@ -9,8 +9,10 @@ import ( "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/nrdcg/nodion" ) @@ -26,6 +28,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIToken string @@ -90,6 +94,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -166,6 +172,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.zoneIDsMu.Lock() zoneID, ok := d.zoneIDs[token] d.zoneIDsMu.Unlock() + if !ok { return fmt.Errorf("nodion: unknown zone ID for '%s' '%s'", info.EffectiveFQDN, token) } @@ -201,5 +208,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("regru: failed to remove TXT records [domain: %s]: %w", dns01.UnFqdn(authZone), err) } + d.zoneIDsMu.Lock() + delete(d.zoneIDs, token) + d.zoneIDsMu.Unlock() + return nil } diff --git a/providers/dns/nodion/nodion.toml b/providers/dns/nodion/nodion.toml index 5bf2e1df1..c9db46e61 100644 --- a/providers/dns/nodion/nodion.toml +++ b/providers/dns/nodion/nodion.toml @@ -6,17 +6,17 @@ Since = "v4.11.0" Example = ''' NODION_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ -lego --email you@example.com --dns nodion -d '*.example.com' -d example.com run +lego --dns nodion -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NODION_API_TOKEN = "The API token" [Configuration.Additional] - NODION_POLLING_INTERVAL = "Time between DNS propagation check" - NODION_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - NODION_TTL = "The TTL of the TXT record used for the DNS challenge" - NODION_HTTP_TIMEOUT = "API request timeout" + NODION_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + NODION_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + NODION_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + NODION_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.nodion.com/en/docs/dns/api/" diff --git a/providers/dns/nodion/nodion_test.go b/providers/dns/nodion/nodion_test.go index fbf4b89eb..0ec5c1627 100644 --- a/providers/dns/nodion/nodion_test.go +++ b/providers/dns/nodion/nodion_test.go @@ -34,6 +34,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -91,6 +92,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -104,6 +106,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/ns1/ns1.go b/providers/dns/ns1/ns1.go index ffa4b1b70..6a7846e85 100644 --- a/providers/dns/ns1/ns1.go +++ b/providers/dns/ns1/ns1.go @@ -7,9 +7,11 @@ import ( "net/http" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "gopkg.in/ns1/ns1-go.v2/rest" "gopkg.in/ns1/ns1-go.v2/rest/model/dns" ) @@ -26,6 +28,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -77,7 +81,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("ns1: credentials missing") } - client := rest.NewClient(config.HTTPClient, rest.SetAPIKey(config.APIKey)) + if config.HTTPClient == nil { + // Because the rest.NewClient uses the http.DefaultClient. + config.HTTPClient = &http.Client{Timeout: 10 * time.Second} + } + + client := rest.NewClient(clientdebug.Wrap(config.HTTPClient), rest.SetAPIKey(config.APIKey)) return &DNSProvider{client: client, config: config}, nil } @@ -138,10 +147,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } name := dns01.UnFqdn(info.EffectiveFQDN) + _, err = d.client.Records.Delete(zone.Zone, name, "TXT") if err != nil { return fmt.Errorf("ns1: failed to delete record [zone: %q, domain: %q]: %w", zone.Zone, name, err) } + return nil } diff --git a/providers/dns/ns1/ns1.toml b/providers/dns/ns1/ns1.toml index 9aeb0841e..829663bf5 100644 --- a/providers/dns/ns1/ns1.toml +++ b/providers/dns/ns1/ns1.toml @@ -6,17 +6,17 @@ Since = "v0.4.0" Example = ''' NS1_API_KEY=xxxx \ -lego --email you@example.com --dns ns1 -d '*.example.com' -d example.com run +lego --dns ns1 -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NS1_API_KEY = "API key" [Configuration.Additional] - NS1_POLLING_INTERVAL = "Time between DNS propagation check" - NS1_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - NS1_TTL = "The TTL of the TXT record used for the DNS challenge" - NS1_HTTP_TIMEOUT = "API request timeout" + NS1_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + NS1_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + NS1_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + NS1_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://ns1.com/api" diff --git a/providers/dns/ns1/ns1_test.go b/providers/dns/ns1/ns1_test.go index 6df6b4afb..82fa70c52 100644 --- a/providers/dns/ns1/ns1_test.go +++ b/providers/dns/ns1/ns1_test.go @@ -37,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -96,6 +97,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -109,6 +111,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/octenium/fixtures/add_dns_record.json b/providers/dns/octenium/fixtures/add_dns_record.json new file mode 100644 index 000000000..25edcdf11 --- /dev/null +++ b/providers/dns/octenium/fixtures/add_dns_record.json @@ -0,0 +1,14 @@ +{ + "api-status": "success", + "api-response": { + "record": { + "type": "TXT", + "name": "_acme-challenge.example.com.", + "ttl": 120, + "value": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", + "raw": { + "txtdata": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI" + } + } + } +} diff --git a/providers/dns/octenium/fixtures/delete_dns_record.json b/providers/dns/octenium/fixtures/delete_dns_record.json new file mode 100644 index 000000000..2aa9415cc --- /dev/null +++ b/providers/dns/octenium/fixtures/delete_dns_record.json @@ -0,0 +1,11 @@ +{ + "api-status": "success", + "api-response": { + "deleted": { + "count": 1, + "lines": [ + 123 + ] + } + } +} diff --git a/providers/dns/octenium/fixtures/list_dns_records.json b/providers/dns/octenium/fixtures/list_dns_records.json new file mode 100644 index 000000000..405afff11 --- /dev/null +++ b/providers/dns/octenium/fixtures/list_dns_records.json @@ -0,0 +1,27 @@ +{ + "api-status": "success", + "api-response": { + "records": [ + { + "line": 31, + "type": "TXT", + "name": "_dmarc.example.com.", + "ttl": 300, + "value": "xxx", + "raw": { + "txtdata": "xxx" + } + }, + { + "line": 123, + "type": "TXT", + "name": "_acme-challenge.example.com.", + "ttl": 300, + "value": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", + "raw": { + "txtdata": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI" + } + } + ] + } +} diff --git a/providers/dns/octenium/fixtures/list_domains.json b/providers/dns/octenium/fixtures/list_domains.json new file mode 100644 index 000000000..a62febcda --- /dev/null +++ b/providers/dns/octenium/fixtures/list_domains.json @@ -0,0 +1,13 @@ +{ + "api-status": "success", + "api-response": { + "domains": { + "2976": { + "domain-name": "example.com", + "registration-date": "21\/08\/2025", + "expiration-date": "-", + "status": "active" + } + } + } +} diff --git a/providers/dns/octenium/internal/client.go b/providers/dns/octenium/internal/client.go new file mode 100644 index 000000000..474770aeb --- /dev/null +++ b/providers/dns/octenium/internal/client.go @@ -0,0 +1,204 @@ +package internal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + querystring "github.com/google/go-querystring/query" +) + +const defaultBaseURL = "https://api.panel.octenium.com/" + +const statusSuccess = "success" + +// Client the Octenium API client. +type Client struct { + apiKey string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(apiKey string) (*Client, error) { + if apiKey == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + apiKey: apiKey, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// ListDomains retrieves a list of domains. +// https://octenium.com/api#tag/Domains/operation/listdomains +func (c *Client) ListDomains(ctx context.Context, domain string) (map[string]Domain, error) { + endpoint := c.BaseURL.JoinPath("domains") + + data := endpoint.Query() + data.Set("domain-name", domain) + endpoint.RawQuery = data.Encode() + + req, err := newRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + result := &DomainsResponse{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result.Domains, nil +} + +// ListDNSRecords retrieves a list of DNS records. +// https://octenium.com/api#tag/Domains-DNS/operation/domains-dns-records-list +func (c *Client) ListDNSRecords(ctx context.Context, orderID, recordType string) ([]Record, error) { + endpoint := c.BaseURL.JoinPath("domains", "dns-records", "list") + + data := make(url.Values) + data.Set("order-id", orderID) + data.Set("types[]", recordType) + + req, err := newRequest(ctx, http.MethodPost, endpoint, data) + if err != nil { + return nil, err + } + + result := &ListRecordsResponse{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result.Records, nil +} + +// AddDNSRecord adds a DNS record. +// https://octenium.com/api#tag/Domains-DNS/operation/domains-dns-records-add +func (c *Client) AddDNSRecord(ctx context.Context, orderID string, record Record) (*Record, error) { + endpoint := c.BaseURL.JoinPath("domains", "dns-records", "add") + + data, err := querystring.Values(record) + if err != nil { + return nil, err + } + + data.Set("order-id", orderID) + + req, err := newRequest(ctx, http.MethodPost, endpoint, data) + if err != nil { + return nil, err + } + + result := &AddRecordResponse{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result.Record, nil +} + +// DeleteDNSRecord deletes a DNS record. +// https://octenium.com/api#tag/Domains-DNS/operation/domains-dns-records-delete +func (c *Client) DeleteDNSRecord(ctx context.Context, orderID string, recordID int) (*DeletedRecordInfo, error) { + endpoint := c.BaseURL.JoinPath("domains", "dns-records", "delete") + + data := make(url.Values) + data.Set("order-id", orderID) + data.Set("line", strconv.Itoa(recordID)) + + req, err := newRequest(ctx, http.MethodPost, endpoint, data) + if err != nil { + return nil, err + } + + result := &DeleteRecordResponse{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result.Deleted, nil +} + +func (c *Client) do(req *http.Request, result any) error { + req.Header.Set("X-Api-Key", c.apiKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + raw, _ := io.ReadAll(resp.Body) + + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + var response APIResponse + + err = json.Unmarshal(raw, &response) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + if response.Status != statusSuccess { + return fmt.Errorf("unexpected status: %s: %s", response.Status, response.Error) + } + + err = json.Unmarshal(response.Response, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, response.Response, err) + } + + return nil +} + +func newRequest(ctx context.Context, method string, endpoint *url.URL, payload url.Values) (*http.Request, error) { + var body io.Reader = http.NoBody + + if method == http.MethodPost && payload != nil { + body = strings.NewReader(payload.Encode()) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if method == http.MethodPost && payload != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + + return req, nil +} diff --git a/providers/dns/octenium/internal/client_test.go b/providers/dns/octenium/internal/client_test.go new file mode 100644 index 000000000..ff1b21961 --- /dev/null +++ b/providers/dns/octenium/internal/client_test.go @@ -0,0 +1,224 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithAccept("application/json"). + With("X-Api-Key", "secret"), + ) +} + +func TestClient_ListDomains(t *testing.T) { + client := mockBuilder(). + Route("GET /domains", + servermock.ResponseFromFixture("list_domains.json"), + servermock.CheckQueryParameter().Strict(). + With("domain-name", "example.com")). + Build(t) + + domains, err := client.ListDomains(t.Context(), "example.com") + require.NoError(t, err) + + expected := map[string]Domain{ + "2976": {DomainName: "example.com", RegistrationDate: "12/09/2021", ExpirationDate: "12/09/2024", Status: "active"}, + "2977": {DomainName: "example.org", RegistrationDate: "01/10/2021", ExpirationDate: "01/10/2024", Status: "active"}, + "2978": {DomainName: "example.net", RegistrationDate: "21/08/2025", ExpirationDate: "-", Status: "active"}, + } + + assert.Equal(t, expected, domains) +} + +func TestClient_ListDomains_error(t *testing.T) { + client := mockBuilder(). + Route("GET /domains", + servermock.Noop().WithStatusCode(http.StatusBadRequest)). + Build(t) + + _, err := client.ListDomains(t.Context(), "example.com") + require.EqualError(t, err, "unexpected status code: [status code: 400] body: ") +} + +func TestClient_ListDomains_api_error(t *testing.T) { + client := mockBuilder(). + Route("GET /domains", + servermock.ResponseFromFixture("error.json")). + Build(t) + + _, err := client.ListDomains(t.Context(), "example.com") + require.EqualError(t, err, "unexpected status: error: missing required fields (type, name, ttl)") +} + +func TestClient_ListDNSRecords(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/dns-records/list", + servermock.ResponseFromFixture("list_dns_records.json"), + servermock.CheckHeader(). + WithContentType("application/x-www-form-urlencoded"), + servermock.CheckForm().Strict(). + With("order-id", "abc"). + With("types[]", "TXT")). + Build(t) + + records, err := client.ListDNSRecords(t.Context(), "abc", "TXT") + require.NoError(t, err) + + expected := []Record{ + {ID: 15, Type: "A", Name: "example.com.", TTL: 14400, Value: "203.0.113.10"}, + {ID: 22, Type: "MX", Name: "example.com.", TTL: 14400, Value: "10 mail.example.com."}, + {ID: 31, Type: "TXT", Name: "_dmarc.example.com.", TTL: 300, Value: "v=DMARC1; p=none; rua=mailto:dmarc@example.com"}, + } + + assert.Equal(t, expected, records) +} + +func TestClient_ListDNSRecords_error(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/dns-records/list", + servermock.Noop().WithStatusCode(http.StatusBadRequest)). + Build(t) + + _, err := client.ListDNSRecords(t.Context(), "abc", "TXT") + require.EqualError(t, err, "unexpected status code: [status code: 400] body: ") +} + +func TestClient_ListDNSRecords_api_error(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/dns-records/list", + servermock.ResponseFromFixture("error.json")). + Build(t) + + _, err := client.ListDNSRecords(t.Context(), "abc", "TXT") + require.EqualError(t, err, "unexpected status: error: missing required fields (type, name, ttl)") +} + +func TestClient_AddDNSRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/dns-records/add", + servermock.ResponseFromFixture("add_dns_record.json"), + servermock.CheckHeader(). + WithContentType("application/x-www-form-urlencoded"), + servermock.CheckForm().Strict(). + With("order-id", "abc"). + With("name", "example.com."). + With("ttl", "120"). + With("type", "TXT"). + With("value", "txtTXTtxt")). + Build(t) + + record := Record{ + Type: "TXT", + Name: "example.com.", + TTL: 120, + Value: "txtTXTtxt", + } + + result, err := client.AddDNSRecord(t.Context(), "abc", record) + require.NoError(t, err) + + expected := &Record{ + Type: "A", + Name: "example.com.", + TTL: 14400, + Value: "203.0.113.10", + } + + assert.Equal(t, expected, result) +} + +func TestClient_AddDNSRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/dns-records/add", + servermock.Noop().WithStatusCode(http.StatusBadRequest)). + Build(t) + + record := Record{ + Type: "TXT", + Name: "example.com.", + TTL: 120, + Value: "txtTXTtxt", + } + + _, err := client.AddDNSRecord(t.Context(), "abc", record) + require.EqualError(t, err, "unexpected status code: [status code: 400] body: ") +} + +func TestClient_AddDNSRecord_api_error(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/dns-records/add", + servermock.ResponseFromFixture("error.json")). + Build(t) + + record := Record{ + Type: "TXT", + Name: "example.com.", + TTL: 120, + Value: "txtTXTtxt", + } + + _, err := client.AddDNSRecord(t.Context(), "abc", record) + require.EqualError(t, err, "unexpected status: error: missing required fields (type, name, ttl)") +} + +func TestClient_DeleteDNSRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/dns-records/delete", + servermock.ResponseFromFixture("delete_dns_record.json"), + servermock.CheckHeader(). + WithContentType("application/x-www-form-urlencoded"), + servermock.CheckForm().Strict(). + With("order-id", "abc"). + With("line", "123")). + Build(t) + + result, err := client.DeleteDNSRecord(t.Context(), "abc", 123) + require.NoError(t, err) + + expected := &DeletedRecordInfo{ + Count: 1, + Lines: []int{15}, + } + + assert.Equal(t, expected, result) +} + +func TestClient_DeleteDNSRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/dns-records/delete", + servermock.Noop().WithStatusCode(http.StatusBadRequest)). + Build(t) + + _, err := client.DeleteDNSRecord(t.Context(), "abc", 123) + require.EqualError(t, err, "unexpected status code: [status code: 400] body: ") +} + +func TestClient_DeleteDNSRecord_api_error(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/dns-records/delete", + servermock.ResponseFromFixture("error.json")). + Build(t) + + _, err := client.DeleteDNSRecord(t.Context(), "abc", 123) + require.EqualError(t, err, "unexpected status: error: missing required fields (type, name, ttl)") +} diff --git a/providers/dns/octenium/internal/fixtures/add_dns_record.json b/providers/dns/octenium/internal/fixtures/add_dns_record.json new file mode 100644 index 000000000..6c73ea1f9 --- /dev/null +++ b/providers/dns/octenium/internal/fixtures/add_dns_record.json @@ -0,0 +1,14 @@ +{ + "api-status": "success", + "api-response": { + "record": { + "type": "A", + "name": "example.com.", + "ttl": 14400, + "value": "203.0.113.10", + "raw": { + "address": "203.0.113.10" + } + } + } +} diff --git a/providers/dns/octenium/internal/fixtures/delete_dns_record.json b/providers/dns/octenium/internal/fixtures/delete_dns_record.json new file mode 100644 index 000000000..0d4692ffd --- /dev/null +++ b/providers/dns/octenium/internal/fixtures/delete_dns_record.json @@ -0,0 +1,11 @@ +{ + "api-status": "success", + "api-response": { + "deleted": { + "count": 1, + "lines": [ + 15 + ] + } + } +} diff --git a/providers/dns/octenium/internal/fixtures/error.json b/providers/dns/octenium/internal/fixtures/error.json new file mode 100644 index 000000000..85a90e425 --- /dev/null +++ b/providers/dns/octenium/internal/fixtures/error.json @@ -0,0 +1,5 @@ +{ + "api-status": "error", + "api-response": [], + "api-error": "missing required fields (type, name, ttl)" +} diff --git a/providers/dns/octenium/internal/fixtures/list_dns_records.json b/providers/dns/octenium/internal/fixtures/list_dns_records.json new file mode 100644 index 000000000..8fa60d86f --- /dev/null +++ b/providers/dns/octenium/internal/fixtures/list_dns_records.json @@ -0,0 +1,38 @@ +{ + "api-status": "success", + "api-response": { + "records": [ + { + "line": 15, + "type": "A", + "name": "example.com.", + "ttl": 14400, + "value": "203.0.113.10", + "raw": { + "address": "203.0.113.10" + } + }, + { + "line": 22, + "type": "MX", + "name": "example.com.", + "ttl": 14400, + "value": "10 mail.example.com.", + "raw": { + "preference": 10, + "exchange": "mail.example.com." + } + }, + { + "line": 31, + "type": "TXT", + "name": "_dmarc.example.com.", + "ttl": 300, + "value": "v=DMARC1; p=none; rua=mailto:dmarc@example.com", + "raw": { + "txtdata": "v=DMARC1; p=none; rua=mailto:dmarc@example.com" + } + } + ] + } +} diff --git a/providers/dns/octenium/internal/fixtures/list_domains.json b/providers/dns/octenium/internal/fixtures/list_domains.json new file mode 100644 index 000000000..b10b705c9 --- /dev/null +++ b/providers/dns/octenium/internal/fixtures/list_domains.json @@ -0,0 +1,25 @@ +{ + "api-status": "success", + "api-response": { + "domains": { + "2976": { + "domain-name": "example.com", + "registration-date": "12/09/2021", + "expiration-date": "12/09/2024", + "status": "active" + }, + "2977": { + "domain-name": "example.org", + "registration-date": "01/10/2021", + "expiration-date": "01/10/2024", + "status": "active" + }, + "2978": { + "domain-name": "example.net", + "registration-date": "21\/08\/2025", + "expiration-date": "-", + "status": "active" + } + } + } +} diff --git a/providers/dns/octenium/internal/types.go b/providers/dns/octenium/internal/types.go new file mode 100644 index 000000000..a31e40921 --- /dev/null +++ b/providers/dns/octenium/internal/types.go @@ -0,0 +1,45 @@ +package internal + +import "encoding/json" + +type APIResponse struct { + Status string `json:"api-status,omitempty"` + Response json.RawMessage `json:"api-response,omitempty"` + Error string `json:"api-error,omitempty"` +} + +type Domain struct { + DomainName string `json:"domain-name,omitempty"` + RegistrationDate string `json:"registration-date,omitempty"` + ExpirationDate string `json:"expiration-date,omitempty"` + Status string `json:"status,omitempty"` +} + +type Record struct { + ID int `json:"line,omitempty" url:"-"` + Type string `json:"type,omitempty" url:"type,omitempty"` + Name string `json:"name,omitempty" url:"name,omitempty"` + TTL int `json:"ttl,omitempty" url:"ttl,omitempty"` + Value string `json:"value,omitempty" url:"value,omitempty"` +} + +type DomainsResponse struct { + Domains map[string]Domain `json:"domains,omitempty"` +} + +type AddRecordResponse struct { + Record *Record `json:"record,omitempty"` +} + +type ListRecordsResponse struct { + Records []Record `json:"records,omitempty"` +} + +type DeleteRecordResponse struct { + Deleted *DeletedRecordInfo `json:"deleted,omitempty"` +} + +type DeletedRecordInfo struct { + Count int `json:"count,omitempty"` + Lines []int `json:"lines,omitempty"` +} diff --git a/providers/dns/octenium/octenium.go b/providers/dns/octenium/octenium.go new file mode 100644 index 000000000..6032dcce1 --- /dev/null +++ b/providers/dns/octenium/octenium.go @@ -0,0 +1,204 @@ +// Package octenium implements a DNS provider for solving the DNS-01 challenge using Octenium. +package octenium + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/log" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/octenium/internal" + "github.com/hashicorp/go-retryablehttp" +) + +// Environment variables names. +const ( + envNamespace = "OCTENIUM_" + + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIKey string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + domainIDs map[string]string + domainIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for Octenium. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("octenium: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Octenium. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("octenium: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.APIKey) + if err != nil { + return nil, fmt.Errorf("octenium: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + retryClient := retryablehttp.NewClient() + retryClient.RetryMax = 5 + retryClient.HTTPClient = client.HTTPClient + retryClient.Logger = log.Logger + + client.HTTPClient = clientdebug.Wrap(retryClient.StandardClient()) + + return &DNSProvider{ + config: config, + client: client, + domainIDs: make(map[string]string), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("octenium: could not find zone for domain '%s': %w", domain, err) + } + + domainID, err := d.getDomainID(ctx, authZone) + if err != nil { + return fmt.Errorf("octenium: get domain ID: %w", err) + } + + d.domainIDsMu.Lock() + d.domainIDs[token] = domainID + d.domainIDsMu.Unlock() + + record := internal.Record{ + Type: "TXT", + Name: info.EffectiveFQDN, + TTL: d.config.TTL, + Value: info.Value, + } + + _, err = d.client.AddDNSRecord(ctx, domainID, record) + if err != nil { + return fmt.Errorf("octenium: add record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + d.domainIDsMu.Lock() + domainID, ok := d.domainIDs[token] + d.domainIDsMu.Unlock() + + if !ok { + return fmt.Errorf("octenium: unknown domain ID for '%s'", info.EffectiveFQDN) + } + + records, err := d.client.ListDNSRecords(ctx, domainID, "TXT") + if err != nil { + return fmt.Errorf("octenium: list records: %w", err) + } + + for _, record := range records { + if record.Type != "TXT" || record.Name != info.EffectiveFQDN || record.Value != info.Value { + continue + } + + _, err = d.client.DeleteDNSRecord(ctx, domainID, record.ID) + if err != nil { + return fmt.Errorf("octenium: delete record: %w", err) + } + + break + } + + d.domainIDsMu.Lock() + delete(d.domainIDs, token) + d.domainIDsMu.Unlock() + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) getDomainID(ctx context.Context, authZone string) (string, error) { + domains, err := d.client.ListDomains(ctx, dns01.UnFqdn(authZone)) + if err != nil { + return "", fmt.Errorf("list domains: %w", err) + } + + if len(domains) == 0 { + return "", errors.New("domain not found") + } + + if len(domains) > 1 { + return "", errors.New("multiple domains found") + } + + for id := range domains { + return id, nil + } + + return "", errors.New("domain ID not found") +} diff --git a/providers/dns/octenium/octenium.toml b/providers/dns/octenium/octenium.toml new file mode 100644 index 000000000..e3c9d894f --- /dev/null +++ b/providers/dns/octenium/octenium.toml @@ -0,0 +1,22 @@ +Name = "Octenium" +Description = '''''' +URL = "https://octenium.com/" +Code = "octenium" +Since = "v4.27.0" + +Example = ''' +OCTENIUM_API_KEY="xxx" \ +lego --dns octenium -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + OCTENIUM_API_KEY = "API key" + [Configuration.Additional] + OCTENIUM_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + OCTENIUM_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + OCTENIUM_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + OCTENIUM_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://octenium.com/api#tag/Domains-DNS" diff --git a/providers/dns/octenium/octenium_test.go b/providers/dns/octenium/octenium_test.go new file mode 100644 index 000000000..dbb8d64b3 --- /dev/null +++ b/providers/dns/octenium/octenium_test.go @@ -0,0 +1,198 @@ +package octenium + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "secret", + }, + }, + { + desc: "missing API key", + envVars: map[string]string{ + EnvAPIKey: "", + }, + expected: "octenium: some credentials information are missing: OCTENIUM_API_KEY", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "octenium: some credentials information are missing: OCTENIUM_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + expected string + }{ + { + desc: "success", + apiKey: "secret", + }, + { + desc: "missing API key", + expected: "octenium: credentials missing", + }, + { + desc: "missing credentials", + expected: "octenium: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIKey = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithAccept("application/json"). + With("X-Api-Key", "secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("GET /domains", + servermock.ResponseFromFixture("list_domains.json"), + servermock.CheckQueryParameter().Strict(). + With("domain-name", "example.com")). + Route("POST /domains/dns-records/add", + servermock.ResponseFromFixture("add_dns_record.json"), + servermock.CheckHeader(). + WithContentType("application/x-www-form-urlencoded"), + servermock.CheckForm().Strict(). + With("order-id", "2976"). + With("name", "_acme-challenge.example.com."). + With("ttl", "120"). + With("type", "TXT"). + With("value", "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI")). + Build(t) + + err := provider.Present("example.com", "", "foobar") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("POST /domains/dns-records/list", + servermock.ResponseFromFixture("list_dns_records.json"), + servermock.CheckHeader(). + WithContentType("application/x-www-form-urlencoded"), + servermock.CheckForm().Strict(). + With("order-id", "2976"). + With("types[]", "TXT")). + Route("POST /domains/dns-records/delete", + servermock.ResponseFromFixture("delete_dns_record.json"), + servermock.CheckHeader(). + WithContentType("application/x-www-form-urlencoded"), + servermock.CheckForm().Strict(). + With("order-id", "2976"). + With("line", "123")). + Build(t) + + provider.domainIDs["token"] = "2976" + + err := provider.CleanUp("example.com", "token", "foobar") + require.NoError(t, err) +} diff --git a/providers/dns/oraclecloud/configprovider.go b/providers/dns/oraclecloud/configprovider.go deleted file mode 100644 index 43d0cecc3..000000000 --- a/providers/dns/oraclecloud/configprovider.go +++ /dev/null @@ -1,97 +0,0 @@ -package oraclecloud - -import ( - "crypto/rsa" - "encoding/base64" - "errors" - "fmt" - "os" - - "github.com/go-acme/lego/v4/platform/config/env" - "github.com/oracle/oci-go-sdk/v65/common" -) - -type configProvider struct { - values map[string]string - privateKeyPassphrase string -} - -func newConfigProvider(values map[string]string) *configProvider { - return &configProvider{ - values: values, - privateKeyPassphrase: env.GetOrFile(EnvPrivKeyPass), - } -} - -func (p *configProvider) PrivateRSAKey() (*rsa.PrivateKey, error) { - privateKey, err := getPrivateKey(envPrivKey) - if err != nil { - return nil, err - } - - return common.PrivateKeyFromBytesWithPassword(privateKey, []byte(p.privateKeyPassphrase)) -} - -func (p *configProvider) KeyID() (string, error) { - tenancy, err := p.TenancyOCID() - if err != nil { - return "", err - } - - user, err := p.UserOCID() - if err != nil { - return "", err - } - - fingerprint, err := p.KeyFingerprint() - if err != nil { - return "", err - } - - return fmt.Sprintf("%s/%s/%s", tenancy, user, fingerprint), nil -} - -func (p *configProvider) TenancyOCID() (value string, err error) { - return p.values[EnvTenancyOCID], nil -} - -func (p *configProvider) UserOCID() (string, error) { - return p.values[EnvUserOCID], nil -} - -func (p *configProvider) KeyFingerprint() (string, error) { - return p.values[EnvPubKeyFingerprint], nil -} - -func (p *configProvider) Region() (string, error) { - return p.values[EnvRegion], nil -} - -func (p *configProvider) AuthType() (common.AuthConfig, error) { - // Inspired by https://github.com/oracle/oci-go-sdk/blob/e7635c292e60d0a9dcdd3a1e7de180d7c99b1eee/common/configuration.go#L231-L234 - return common.AuthConfig{AuthType: common.UnknownAuthenticationType}, errors.New("unsupported, keep the interface") -} - -func getPrivateKey(envVar string) ([]byte, error) { - envVarValue := os.Getenv(envVar) - if envVarValue != "" { - bytes, err := base64.StdEncoding.DecodeString(envVarValue) - if err != nil { - return nil, fmt.Errorf("failed to read base64 value %s (defined by env var %s): %w", envVarValue, envVar, err) - } - return bytes, nil - } - - fileVar := envVar + "_FILE" - fileVarValue := os.Getenv(fileVar) - if fileVarValue == "" { - return nil, fmt.Errorf("no value provided for: %s or %s", envVar, fileVar) - } - - fileContents, err := os.ReadFile(fileVarValue) - if err != nil { - return nil, fmt.Errorf("failed to read the file %s (defined by env var %s): %w", fileVarValue, fileVar, err) - } - - return fileContents, nil -} diff --git a/providers/dns/oraclecloud/configurationprovider.go b/providers/dns/oraclecloud/configurationprovider.go new file mode 100644 index 000000000..97710108c --- /dev/null +++ b/providers/dns/oraclecloud/configurationprovider.go @@ -0,0 +1,144 @@ +package oraclecloud + +import ( + "crypto/rsa" + "encoding/base64" + "errors" + "fmt" + "os" + "slices" + "strings" + + "github.com/go-acme/lego/v4/log" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/nrdcg/oci-go-sdk/common/v1065" +) + +type environmentConfigurationProvider struct { + values map[string]string +} + +func newEnvironmentConfigurationProvider() (*environmentConfigurationProvider, error) { + values, err := env.GetWithFallback( + []string{EnvRegion, altEnvTFVarRegion}, + []string{EnvUserOCID, altEnvTFVarUserOCID}, + []string{EnvTenancyOCID, altEnvTFVarTenancyOCID}, + []string{EnvPubKeyFingerprint, altEnvFingerprint, altEnvTFVarFingerprint}, + ) + if err != nil { + return nil, err + } + + return &environmentConfigurationProvider{ + values: values, + }, nil +} + +func (p *environmentConfigurationProvider) PrivateRSAKey() (*rsa.PrivateKey, error) { + privateKey, err := getPrivateKey() + if err != nil { + return nil, err + } + + return common.PrivateKeyFromBytesWithPassword(privateKey, []byte(p.privateKeyPassword())) +} + +func (p *environmentConfigurationProvider) KeyID() (string, error) { + tenancy, err := p.TenancyOCID() + if err != nil { + return "", err + } + + user, err := p.UserOCID() + if err != nil { + return "", err + } + + fingerprint, err := p.KeyFingerprint() + if err != nil { + return "", err + } + + return fmt.Sprintf("%s/%s/%s", tenancy, user, fingerprint), nil +} + +func (p *environmentConfigurationProvider) TenancyOCID() (string, error) { + return p.values[EnvTenancyOCID], nil +} + +func (p *environmentConfigurationProvider) UserOCID() (string, error) { + return p.values[EnvUserOCID], nil +} + +func (p *environmentConfigurationProvider) KeyFingerprint() (string, error) { + return p.values[EnvPubKeyFingerprint], nil +} + +func (p *environmentConfigurationProvider) Region() (string, error) { + return p.values[EnvRegion], nil +} + +func (p *environmentConfigurationProvider) AuthType() (common.AuthConfig, error) { + // Inspired by https://github.com/oracle/oci-go-sdk/blob/e7635c292e60d0a9dcdd3a1e7de180d7c99b1eee/common/configuration.go#L231-L234 + return common.AuthConfig{AuthType: common.UnknownAuthenticationType}, errors.New("unsupported, keep the interface") +} + +func (p *environmentConfigurationProvider) privateKeyPassword() string { + return env.GetOneWithFallback(EnvPrivKeyPass, "", env.ParseString, altEnvPrivateKeyPassword, altEnvTFVarPrivateKeyPassword) +} + +func getPrivateKey() ([]byte, error) { + base64EnvKeys := []string{envPrivKey, altEnvPrivateKey} + + envVarValue := getEnvWithStrictFallback(base64EnvKeys...) + if envVarValue != "" { + bytes, err := base64.StdEncoding.DecodeString(envVarValue) + if err != nil { + return nil, fmt.Errorf("failed to read base64 value %s (defined by env vars %s): %w", envVarValue, + strings.Join(base64EnvKeys, " or "), err) + } + + return bytes, nil + } + + fileEnvKeys := []string{EnvPrivKeyFile, altEnvPrivateKeyPath, altEnvTFVarPrivateKeyPath} + + fileVarValue := getEnvFileWithStrictFallback(fileEnvKeys...) + if len(fileVarValue) == 0 { + return nil, fmt.Errorf("no value provided for: %s", + strings.Join(slices.Concat(base64EnvKeys, fileEnvKeys), " or "), + ) + } + + return fileVarValue, nil +} + +func getEnvWithStrictFallback(keys ...string) string { + for _, key := range keys { + envVarValue := os.Getenv(key) + if envVarValue != "" { + return envVarValue + } + } + + return "" +} + +func getEnvFileWithStrictFallback(keys ...string) []byte { + for _, key := range keys { + fileVarValue := os.Getenv(key) + if fileVarValue == "" { + continue + } + + fileContents, err := os.ReadFile(fileVarValue) + if err != nil { + log.Printf("Failed to read the file %s (defined by env var %s): %s", fileVarValue, key, err) + return nil + } + + return fileContents + } + + return nil +} diff --git a/providers/dns/oraclecloud/fixtures/cert.pem b/providers/dns/oraclecloud/fixtures/cert.pem new file mode 100644 index 000000000..fc1dcfb53 --- /dev/null +++ b/providers/dns/oraclecloud/fixtures/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDHzCCAgegAwIBAgIQKIExaCLIXtXecrT1dWGLszANBgkqhkiG9w0BAQsFADAS +MRAwDgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYw +MDAwWjASMRAwDgYDVQQKEwdBY21lIENvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAwM4wEPHOGAu8tZNNWx3cH6AMuqKwAmB2RwbA3OK034MzhydOjnDm +igw93eUc4nd3dnICyNpb2rbP9FgGlAuMlJ8raHQkG4DSXF1Bf14neOhLpfBItaX9 ++EB3oO0NupKZhaHrsTKzLGD7bauAPX6PDXuAPp3u5mgGGuZjpLZoKqg3//WImb/2 +xEMVsmvPKTb5FxS/tAMtywjGSUtCTCrudUEh4Gnj6IboVdwYmt539ETDK/Rerxf3 +/GsmEbuOkDUdBixQwLo0U+UAoMOw4zoyQDrrtyUmvffDxI50RAdZDFyFtqZ0ZQa8 +lQqrMdQdf+x1Wb7BKozSktAw4igRP/mknQIDAQABo28wbTAOBgNVHQ8BAf8EBAMC +AqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E +FgQUcetTliVbYxxutNS8JRkotRY4DRkwFgYDVR0RBA8wDYILZXhhbXBsZS5vcmcw +DQYJKoZIhvcNAQELBQADggEBAEJP74/XB+12aGQ+EMERIX2Pn6YaaBLt6rTLqV7A +zFxI9YGIc4xlGa0qkpDhpz6RSypTQG6HN5aZ5b8dz3foMleUVP2cXd8zduc8GQCb +p4/8PpEhSl6dQb5+mg/qyHGUAaDl40VAbTLXHtn98dhacaJc+TKuXVJAgYRU3Sm3 +wFJxULZSnx+aGdE9s2brOGhvz1fVWnhvWzDvJSM+8xDURz8UiEnimTpV6m3CKItz +2GatNjM8ADKC7MHQI4I5v4fEwronN/g3NfPfFSmnOKk+lPSAW42WEvhFol+2VvdX +3p5X2QracSLCIj/DUBebZP9110C8Lj/YfFtOjFokqtQ9Fh4= +-----END CERTIFICATE----- diff --git a/providers/dns/oraclecloud/fixtures/key.pem b/providers/dns/oraclecloud/fixtures/key.pem new file mode 100644 index 000000000..1a56bb5a4 --- /dev/null +++ b/providers/dns/oraclecloud/fixtures/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDAzjAQ8c4YC7y1 +k01bHdwfoAy6orACYHZHBsDc4rTfgzOHJ06OcOaKDD3d5Rzid3d2cgLI2lvats/0 +WAaUC4yUnytodCQbgNJcXUF/Xid46Eul8Ei1pf34QHeg7Q26kpmFoeuxMrMsYPtt +q4A9fo8Ne4A+ne7maAYa5mOktmgqqDf/9YiZv/bEQxWya88pNvkXFL+0Ay3LCMZJ +S0JMKu51QSHgaePohuhV3Bia3nf0RMMr9F6vF/f8ayYRu46QNR0GLFDAujRT5QCg +w7DjOjJAOuu3JSa998PEjnREB1kMXIW2pnRlBryVCqsx1B1/7HVZvsEqjNKS0DDi +KBE/+aSdAgMBAAECggEAWl2pWJ/ErS9/HIl0NbMKk0YEAUuz/AEzHnoTVdPp22KW +eY+aOZe/7c7sBj7WqWw98SVhmbsCV0HcuNSzDJtXIedyRGw+6icYMVNCGgzKqlgR +8K3snjq1DLBGgYXpq9r/Got4ON6e7LttzIqXufrB2JtcUbzbFmGGDwCRjkcyDl9l +M8ufwD/Xgcd2L8jainU43d2pVxvxUIpRlRdoupCCSlkRYPsXiWlqav7YO4F/Txos +z3gJyzkXzc3WwfNZdQtEMYwBwozO+Dp2p4TUBr0Ta3MbfrKfDoTs4XT/Ce9IwJJS +/h6E9cxZD8t5oMT50quFjwhHBKodMiUqIlh2YQEAbwKBgQDIULzo/tgDgTwveyEn +L9n8yVbEh/SfrE9QtXcjkDB5+tYmIsIaz16NRWlAqnJVGZvcanrCq7ZTxgUcs/hW +Ag+sfWkeg7lmfeJAkiZ6kmi1h2qJjXMOBri+Cm6MTOsE6qdIc3eT4PnYkNpV7o6S +70hWNncVadXLV4Thm9BLAbMbQwKBgQD2ZwKe/2zRQcbuBe1loF0HWIsJPxcKQ3LH +hVf7f0YLQlIuzOhK8TQXgM0G4hxLlk1XeLjgf3z4Ju7hfh2JQLor1QYPRGUj66SX +KTE5eDwE0yEX1c9m5PW6M+f8vkOU4LQ/OtPw5OrKyYxpLf9dp42nmDYY/8IvUk96 +iKZNY1sSnwKBgQC27tS2SxVmjf0yt1WdfdurOQueSzKhJzD/2djFh4Zdvy8WgKOW +7E3C4eKvBXmIMezeq/cUFNBbTPmaLtjZYuSBd74p+c20xb17jnzJby9kqBgpKh4q +bwUDuG8gfZYbVVgTmC9ZwxkoJ5Dc7RETKqZ65R53VcHDA1f82Nitxw2UFQKBgBDl +c2qPvViEGC4OPf8wBfERA0e5Cc1sXpyL6kKWsajn/Va0OmGZNKc/788/Bg2w2tDa +uGK8m0cw9ESGL2RQCfQjgWzelcjmybyL2JJGSmdSSvylbrlxjeAc2xWbvmqhFfsX +/5yPNgJ926ECxHYZnT8W0u7X6urvy/9tC2pXG9GlAoGBAKOAfij4fMbHY+Z1m825 +VhY110FDnePYFJWmExP8GAVqOzhCs0mzyCnYh6nvS/OY8moH2LOuwPUlDfF3IzyT +hTUuXnykWT3w40eYQXXIaXEGhue+guL8ch16vEEJy5ltwEdIPNMTErbqAAk2W6Ps +NB46HzETzEIWnzoamX6iQVWj +-----END PRIVATE KEY----- diff --git a/providers/dns/oraclecloud/oraclecloud.go b/providers/dns/oraclecloud/oraclecloud.go index a55613810..730b3f212 100644 --- a/providers/dns/oraclecloud/oraclecloud.go +++ b/providers/dns/oraclecloud/oraclecloud.go @@ -8,24 +8,35 @@ import ( "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/oracle/oci-go-sdk/v65/common" - "github.com/oracle/oci-go-sdk/v65/dns" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/nrdcg/oci-go-sdk/common/v1065" + "github.com/nrdcg/oci-go-sdk/common/v1065/auth" + "github.com/nrdcg/oci-go-sdk/dns/v1065" ) // Environment variables names. const ( envNamespace = "OCI_" - EnvCompartmentOCID = envNamespace + "COMPARTMENT_OCID" + EnvAuthType = envNamespace + "AUTH_TYPE" + + EnvCompartmentOCID = envNamespace + "COMPARTMENT_OCID" + EnvRegion = envNamespace + "REGION" + envPrivKey = envNamespace + "PRIVKEY" EnvPrivKeyFile = envPrivKey + "_FILE" EnvPrivKeyPass = envPrivKey + "_PASS" EnvTenancyOCID = envNamespace + "TENANCY_OCID" EnvUserOCID = envNamespace + "USER_OCID" EnvPubKeyFingerprint = envNamespace + "PUBKEY_FINGERPRINT" - EnvRegion = envNamespace + "REGION" + + altEnvPrivateKey = envNamespace + "PRIVATE_KEY" // alias on OCI_PRIVKEY + altEnvPrivateKeyPath = altEnvPrivateKey + "_PATH" // alias on OCI_PRIVKEY_FILE + altEnvPrivateKeyPassword = altEnvPrivateKey + "_PASSWORD" // alias on OCI_PRIVKEY_PASS + altEnvFingerprint = envNamespace + "FINGERPRINT" // alias on OCI_PUBKEY_FINGERPRINT EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" @@ -33,10 +44,25 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +// https://github.com/oracle/oci-go-sdk/blob/7f425f74c74fd0c6a5acb74466c85eb5346e0092/common/client.go#L350 +// https://github.com/oracle/oci-go-sdk/blob/7f425f74c74fd0c6a5acb74466c85eb5346e0092/common/configuration.go#L174-L175 +const ( + altEnvTFVarNamespace = "TF_VAR_" + altEnvTFVarRegion = altEnvTFVarNamespace + "region" // alias on OCI_REGION + altEnvTFVarFingerprint = altEnvTFVarNamespace + "fingerprint" // alias on OCI_PUBKEY_FINGERPRINT + altEnvTFVarUserOCID = altEnvTFVarNamespace + "user_ocid" // alias on OCI_USER_OCID + altEnvTFVarTenancyOCID = altEnvTFVarNamespace + "tenancy_ocid" // alias on OCI_TENANCY_OCID + altEnvTFVarPrivateKeyPath = altEnvTFVarNamespace + "private_key_path" // alias on OCI_PRIVKEY_FILE + altEnvTFVarPrivateKeyPassword = altEnvTFVarNamespace + "private_key_password" // alias on OCI_PRIVKEY_PASS +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { - CompartmentID string - OCIConfigProvider common.ConfigurationProvider + CompartmentID string + OCIConfigProvider common.ConfigurationProvider + PropagationTimeout time.Duration PollingInterval time.Duration TTL int @@ -50,7 +76,7 @@ func NewDefaultConfig() *Config { PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ - Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 60*time.Second), + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, time.Minute), }, } } @@ -63,14 +89,41 @@ type DNSProvider struct { // NewDNSProvider returns a DNSProvider instance configured for OracleCloud. func NewDNSProvider() (*DNSProvider, error) { - values, err := env.Get(envPrivKey, EnvTenancyOCID, EnvUserOCID, EnvPubKeyFingerprint, EnvRegion, EnvCompartmentOCID) - if err != nil { - return nil, fmt.Errorf("oraclecloud: %w", err) - } - config := NewDefaultConfig() - config.CompartmentID = values[EnvCompartmentOCID] - config.OCIConfigProvider = newConfigProvider(values) + + switch env.GetOrFile(EnvAuthType) { + case string(common.InstancePrincipal): + values, err := env.Get(EnvCompartmentOCID) + if err != nil { + return nil, fmt.Errorf("oraclecloud: %w", err) + } + + config.CompartmentID = values[EnvCompartmentOCID] + + region := env.GetOneWithFallback(EnvRegion, "", env.ParseString, altEnvTFVarRegion) + + configurationProvider, err := auth.InstancePrincipalConfigurationProviderForRegion(common.Region(region)) + if err != nil { + return nil, fmt.Errorf("oraclecloud: %w", err) + } + + config.OCIConfigProvider = configurationProvider + + default: + values, err := env.Get(EnvCompartmentOCID) + if err != nil { + return nil, fmt.Errorf("oraclecloud: %w", err) + } + + config.CompartmentID = values[EnvCompartmentOCID] + + ecp, err := newEnvironmentConfigurationProvider() + if err != nil { + return nil, fmt.Errorf("oraclecloud: %w", err) + } + + config.OCIConfigProvider = ecp + } return NewDNSProviderConfig(config) } @@ -95,7 +148,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { } if config.HTTPClient != nil { - client.HTTPClient = config.HTTPClient + client.HTTPClient = clientdebug.Wrap(config.HTTPClient) } return &DNSProvider{client: &client, config: config}, nil @@ -165,7 +218,8 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } var deleteHash *string - for _, record := range domainRecords.RecordCollection.Items { + + for _, record := range domainRecords.Items { if record.Rdata != nil && *record.Rdata == `"`+info.Value+`"` { deleteHash = record.RecordHash break diff --git a/providers/dns/oraclecloud/oraclecloud.toml b/providers/dns/oraclecloud/oraclecloud.toml index 70b776554..f6155052e 100644 --- a/providers/dns/oraclecloud/oraclecloud.toml +++ b/providers/dns/oraclecloud/oraclecloud.toml @@ -5,29 +5,43 @@ Code = "oraclecloud" Since = "v2.3.0" Example = ''' -OCI_PRIVKEY_FILE="~/.oci/oci_api_key.pem" \ -OCI_PRIVKEY_PASS="secret" \ +# Using API Key authentication: +OCI_PRIVATE_KEY_PATH="~/.oci/oci_api_key.pem" \ +OCI_PRIVATE_KEY_PASSWORD="secret" \ OCI_TENANCY_OCID="ocid1.tenancy.oc1..secret" \ OCI_USER_OCID="ocid1.user.oc1..secret" \ -OCI_PUBKEY_FINGERPRINT="00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00" \ +OCI_FINGERPRINT="00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00" \ OCI_REGION="us-phoenix-1" \ OCI_COMPARTMENT_OCID="ocid1.tenancy.oc1..secret" \ -lego --email you@example.com --dns oraclecloud -d '*.example.com' -d example.com run +lego --dns oraclecloud -d '*.example.com' -d example.com run + +# Using Instance Principal authentication (when running on OCI compute instances): +# https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/callingservicesfrominstances.htm +OCI_AUTH_TYPE="instance_principal" \ +OCI_COMPARTMENT_OCID="ocid1.tenancy.oc1..secret" \ +lego --dns oraclecloud -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] - OCI_PRIVKEY_FILE = "Private key file" - OCI_PRIVKEY_PASS = "Private key password" - OCI_TENANCY_OCID = "Tenancy OCID" - OCI_USER_OCID = "User OCID" - OCI_PUBKEY_FINGERPRINT = "Public key fingerprint" - OCI_REGION = "Region" OCI_COMPARTMENT_OCID = "Compartment OCID" + OCI_REGION = "Region (it can be empty if `OCI_AUTH_TYPE=instance_principal`)." + OCI_PRIVATE_KEY_PATH = "Private key file (ignored if `OCI_AUTH_TYPE=instance_principal`)" + OCI_PRIVATE_KEY_PASSWORD = "Private key password (ignored if `OCI_AUTH_TYPE=instance_principal`)" + OCI_TENANCY_OCID = "Tenancy OCID (ignored if `OCI_AUTH_TYPE=instance_principal`)" + OCI_USER_OCID = "User OCID (ignored if `OCI_AUTH_TYPE=instance_principal`)" + OCI_FINGERPRINT = "Public key fingerprint (ignored if `OCI_AUTH_TYPE=instance_principal`)" [Configuration.Additional] - OCI_POLLING_INTERVAL = "Time between DNS propagation check" - OCI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - OCI_TTL = "The TTL of the TXT record used for the DNS challenge" + OCI_AUTH_TYPE = "Authorization type. Possible values: 'instance_principal', '' (Default: '')" + TF_VAR_region = "Alias on `OCI_REGION`" + TF_VAR_fingerprint = "Alias on `OCI_FINGERPRINT`" + TF_VAR_user_ocid = "Alias on `OCI_USER_OCID`" + TF_VAR_tenancy_ocid = "Alias on `OCI_TENANCY_OCID`" + TF_VAR_private_key_path = "Alias on `OCI_PRIVATE_KEY_PATH`" + OCI_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + OCI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + OCI_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + OCI_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" [Links] API = "https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm" diff --git a/providers/dns/oraclecloud/oraclecloud_test.go b/providers/dns/oraclecloud/oraclecloud_test.go index 9fff79ea1..74ee06eac 100644 --- a/providers/dns/oraclecloud/oraclecloud_test.go +++ b/providers/dns/oraclecloud/oraclecloud_test.go @@ -6,19 +6,31 @@ import ( "crypto/x509" "encoding/base64" "encoding/pem" + "maps" + "net/http/httptest" "os" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" - "github.com/oracle/oci-go-sdk/v65/common" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/nrdcg/oci-go-sdk/common/v1065" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" +// Used by Instance Principal authentication. +const ( + envMetadataBaseURL = "OCI_METADATA_BASE_URL" + envSDKAuthClientRegionURL = "OCI_SDK_AUTH_CLIENT_REGION_URL" +) + var envTest = tester.NewEnvTest( envPrivKey, + EnvAuthType, + envMetadataBaseURL, + envSDKAuthClientRegionURL, EnvPrivKeyFile, EnvPrivKeyPass, EnvTenancyOCID, @@ -49,7 +61,7 @@ func TestNewDNSProvider(t *testing.T) { { desc: "success file", envVars: map[string]string{ - EnvPrivKeyFile: mustGeneratePrivateKeyFile("secret1"), + EnvPrivKeyFile: mustGeneratePrivateKeyFile(t, "secret1"), EnvPrivKeyPass: "secret1", EnvTenancyOCID: "ocid1.tenancy.oc1..secret", EnvUserOCID: "ocid1.user.oc1..secret", @@ -61,7 +73,7 @@ func TestNewDNSProvider(t *testing.T) { { desc: "missing credentials", envVars: map[string]string{}, - expected: "oraclecloud: some credentials information are missing: OCI_PRIVKEY,OCI_TENANCY_OCID,OCI_USER_OCID,OCI_PUBKEY_FINGERPRINT,OCI_REGION,OCI_COMPARTMENT_OCID", + expected: "oraclecloud: some credentials information are missing: OCI_COMPARTMENT_OCID", }, { desc: "missing CompartmentID", @@ -87,7 +99,7 @@ func TestNewDNSProvider(t *testing.T) { EnvRegion: "us-phoenix-1", EnvCompartmentOCID: "123", }, - expected: "oraclecloud: some credentials information are missing: OCI_PRIVKEY", + expected: "oraclecloud: can not create client, bad configuration: no value provided for: OCI_PRIVKEY or OCI_PRIVATE_KEY or OCI_PRIVKEY_FILE or OCI_PRIVATE_KEY_PATH or TF_VAR_private_key_path", }, { desc: "missing OCI_PRIVKEY_PASS", @@ -176,8 +188,10 @@ func TestNewDNSProvider(t *testing.T) { if privKeyFile != "" { _ = os.Remove(privKeyFile) } + envTest.RestoreEnv() }() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -197,6 +211,74 @@ func TestNewDNSProvider(t *testing.T) { } } +func TestNewDNSProvider_instance_principal(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAuthType: "instance_principal", + EnvCompartmentOCID: "123", + }, + }, + { + desc: "missing CompartmentID", + envVars: map[string]string{ + EnvAuthType: "instance_principal", + }, + expected: "oraclecloud: some credentials information are missing: OCI_COMPARTMENT_OCID", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer func() { + envTest.RestoreEnv() + }() + + envTest.ClearEnv() + + serverURL := servermock.NewBuilder( + func(server *httptest.Server) (string, error) { + return server.URL, nil + }). + Route("GET /instance/region", servermock.RawStringResponse("oc1")). + // To generate fake certificates: + // go run `go env GOROOT`/src/crypto/tls/generate_cert.go --host example.org --ca --start-date "Jan 1 00:00:00 1970" --duration=1000000h + Route("GET /identity/cert.pem", servermock.ResponseFromFixture("cert.pem")). + Route("GET /identity/key.pem", servermock.ResponseFromFixture("key.pem")). + Route("GET /identity/intermediate.pem", servermock.ResponseFromFixture("cert.pem")). + // https://github.com/oracle/oci-go-sdk/blob/413a2f277f95c5eb76e26a0e0833c396a518bf50/common/auth/jwt_test.go#L12 + Route("POST /v1/x509", servermock.RawStringResponse(`{"token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImFzdyIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJvcGMub3JhY2xlLmNvbSIsImV4cCI6MTUxMTgzODc5MywiaWF0IjoxNTExODE3MTkzLCJpc3MiOiJhdXRoU2VydmljZS5vcmFjbGUuY29tIiwib3BjLWNlcnR0eXBlIjoiaW5zdGFuY2UiLCJvcGMtY29tcGFydG1lbnQiOiJvY2lkMS5jb21wYXJ0bWVudC5vYzEuLmJsdWhibHVoYmx1aCIsIm9wYy1pbnN0YW5jZSI6Im9jaWQxLmluc3RhbmNlLm9jMS5waHguYmx1aGJsdWhibHVoIiwib3BjLXRlbmFudCI6Im9jaWR2MTp0ZW5hbmN5Om9jMTpwaHg6MTIzNDU2Nzg5MDpibHVoYmx1aGJsdWgiLCJwdHlwZSI6Imluc3RhbmNlIiwic3ViIjoib2NpZDEuaW5zdGFuY2Uub2MxLnBoeC5ibHVoYmx1aGJsdWgiLCJ0ZW5hbnQiOiJvY2lkdjE6dGVuYW5jeTpvYzE6cGh4OjEyMzQ1Njc4OTA6Ymx1aGJsdWhibHVoIiwidHR5cGUiOiJ4NTA5In0.zen7q2yJSpMjzH4ym_H7VEwZA0-vTT4Wcild-HRfLxX6A1ej4tlpACa7A24j5JoZYI4mHooZVJ8e7ZezFenK0zZx5j8RbIjsqJKwroYXExOiBXLCUwMWOLXIndEsUzzGLqnPfKHXd80vrhMLmtkVTCJqBMzvPUSYkH_ciWgmjP9m0YETdQ9ifghkADhZGt9IlnOswg0s3Bx9ASwxFZEtom0BmU9GwEuITTTZfKvndk785BlNeZMOjhovaD97-LYpv5B_PiWEz8zialK5zxjijLCw06zyA8CQRQqmVCagNUPilfz_BcPyImzvFDuzQcPyDkTcsB7weX35tafHmA_Ul"}`)). + Build(t) + + envVars := map[string]string{ + envMetadataBaseURL: serverURL, + envSDKAuthClientRegionURL: serverURL, + } + + maps.Copy(envVars, test.envVars) + + envTest.Apply(envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), test.expected) + } + }) + } +} + func TestNewDNSProviderConfig(t *testing.T) { envTest.ClearEnv() defer envTest.RestoreEnv() @@ -251,6 +333,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -264,6 +347,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -273,21 +357,20 @@ func TestLiveCleanUp(t *testing.T) { require.NoError(t, err) } -func mockConfigurationProvider(keyPassphrase string) *configProvider { +func mockConfigurationProvider(keyPassphrase string) *environmentConfigurationProvider { envTest.Apply(map[string]string{ envPrivKey: mustGeneratePrivateKey("secret"), }) - return &configProvider{ + return &environmentConfigurationProvider{ values: map[string]string{ EnvCompartmentOCID: "test", - EnvPrivKeyPass: "test", + EnvPrivKeyPass: keyPassphrase, EnvTenancyOCID: "test", EnvUserOCID: "test", EnvPubKeyFingerprint: "test", EnvRegion: "test", }, - privateKeyPassphrase: keyPassphrase, } } @@ -300,27 +383,27 @@ func mustGeneratePrivateKey(pwd string) string { return base64.StdEncoding.EncodeToString(pem.EncodeToMemory(block)) } -func mustGeneratePrivateKeyFile(pwd string) string { - block, err := generatePrivateKey(pwd) - if err != nil { - panic(err) - } +func mustGeneratePrivateKeyFile(t *testing.T, pwd string) string { + t.Helper() - file, err := os.CreateTemp("", "lego_oci_*.pem") - if err != nil { - panic(err) - } + block, err := generatePrivateKey(pwd) + require.NoError(t, err) + + file, err := os.CreateTemp(t.TempDir(), "lego_oci_*.pem") + require.NoError(t, err) + + defer func() { + _ = file.Close() + }() err = pem.Encode(file, block) - if err != nil { - panic(err) - } + require.NoError(t, err) return file.Name() } func generatePrivateKey(pwd string) (*pem.Block, error) { - key, err := rsa.GenerateKey(rand.Reader, 512) + key, err := rsa.GenerateKey(rand.Reader, 1024) if err != nil { return nil, err } diff --git a/providers/dns/otc/internal/client.go b/providers/dns/otc/internal/client.go index 59a685140..adb0682e1 100644 --- a/providers/dns/otc/internal/client.go +++ b/providers/dns/otc/internal/client.go @@ -31,7 +31,7 @@ type Client struct { HTTPClient *http.Client } -func NewClient(username string, password string, domainName string, projectName string) *Client { +func NewClient(username, password, domainName, projectName string) *Client { return &Client{ username: username, password: password, @@ -42,8 +42,8 @@ func NewClient(username string, password string, domainName string, projectName } } -func (c *Client) GetZoneID(ctx context.Context, zone string) (string, error) { - zonesResp, err := c.getZones(ctx, zone) +func (c *Client) GetZoneID(ctx context.Context, zone string, privateZone bool) (string, error) { + zonesResp, err := c.getZones(ctx, zone, privateZone) if err != nil { return "", err } @@ -62,13 +62,18 @@ func (c *Client) GetZoneID(ctx context.Context, zone string) (string, error) { } // https://docs.otc.t-systems.com/domain-name-service/api-ref/apis/public_zone_management/querying_public_zones.html -func (c *Client) getZones(ctx context.Context, zone string) (*ZonesResponse, error) { +func (c *Client) getZones(ctx context.Context, zone string, privateZone bool) (*ZonesResponse, error) { c.muBaseURL.Lock() endpoint := c.baseURL.JoinPath("zones") c.muBaseURL.Unlock() query := endpoint.Query() query.Set("name", zone) + + if privateZone { + query.Set("type", "private") + } + endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -77,6 +82,7 @@ func (c *Client) getZones(ctx context.Context, zone string) (*ZonesResponse, err } var zones ZonesResponse + err = c.do(req, &zones) if err != nil { return nil, err @@ -123,6 +129,7 @@ func (c *Client) getRecordSet(ctx context.Context, zoneID, fqdn string) (*Record } var recordSetsRes RecordSetsResponse + err = c.do(req, &recordSetsRes) if err != nil { return nil, err @@ -163,9 +170,11 @@ func (c *Client) DeleteRecordSet(ctx context.Context, zoneID, recordID string) e func (c *Client) do(req *http.Request, result any) error { c.muToken.Lock() + if c.token != "" { req.Header.Set("X-Auth-Token", c.token) } + c.muToken.Unlock() resp, err := c.HTTPClient.Do(req) @@ -196,7 +205,7 @@ func (c *Client) do(req *http.Request, result any) error { return nil } -func newJSONRequest[T string | *url.URL](ctx context.Context, method string, endpoint T, payload interface{}) (*http.Request, error) { +func newJSONRequest[T string | *url.URL](ctx context.Context, method string, endpoint T, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { diff --git a/providers/dns/otc/internal/client_test.go b/providers/dns/otc/internal/client_test.go new file mode 100644 index 000000000..74b5bb3af --- /dev/null +++ b/providers/dns/otc/internal/client_test.go @@ -0,0 +1,125 @@ +package internal + +import ( + "context" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret", "example.com", "test") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) +} + +func TestClient_GetZoneID(t *testing.T) { + client := mockBuilder(). + Route("GET /zones", + servermock.ResponseFromFixture("zones_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com.")). + Build(t) + + zoneID, err := client.GetZoneID(context.Background(), "example.com.", false) + require.NoError(t, err) + + assert.Equal(t, "123123", zoneID) +} + +func TestClient_GetZoneID_private(t *testing.T) { + client := mockBuilder(). + Route("GET /zones", + servermock.ResponseFromFixture("zones_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com."). + With("type", "private")). + Build(t) + + zoneID, err := client.GetZoneID(context.Background(), "example.com.", true) + require.NoError(t, err) + + assert.Equal(t, "123123", zoneID) +} + +func TestClient_GetZoneID_error(t *testing.T) { + client := mockBuilder(). + Route("GET /zones", + servermock.ResponseFromFixture("zones_GET_empty.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com.")). + Build(t) + + _, err := client.GetZoneID(context.Background(), "example.com.", false) + require.EqualError(t, err, "zone example.com. not found") +} + +func TestClient_GetRecordSetID(t *testing.T) { + client := mockBuilder(). + Route("GET /zones/123123/recordsets", + servermock.ResponseFromFixture("zones-recordsets_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com."). + With("type", "TXT"), + ). + Build(t) + + recordSetID, err := client.GetRecordSetID(context.Background(), "123123", "example.com.") + require.NoError(t, err) + + assert.Equal(t, "321321", recordSetID) +} + +func TestClient_GetRecordSetID_error(t *testing.T) { + client := mockBuilder(). + Route("GET /zones/123123/recordsets", + servermock.ResponseFromFixture("zones-recordsets_GET_empty.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com."). + With("type", "TXT"), + ). + Build(t) + + _, err := client.GetRecordSetID(context.Background(), "123123", "example.com.") + require.EqualError(t, err, "record not found") +} + +func TestClient_CreateRecordSet(t *testing.T) { + client := mockBuilder(). + Route("POST /zones/123123/recordsets", + servermock.ResponseFromFixture("zones-recordsets_POST.json"), + servermock.CheckRequestJSONBodyFromFixture("zones-recordsets_POST-request.json")). + Build(t) + + rs := RecordSets{ + Name: "_acme-challenge.example.com.", + Description: "Added TXT record for ACME dns-01 challenge using lego client", + Type: "TXT", + TTL: 300, + Records: []string{strconv.Quote("ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY")}, + } + err := client.CreateRecordSet(context.Background(), "123123", rs) + require.NoError(t, err) +} + +func TestClient_DeleteRecordSet(t *testing.T) { + client := mockBuilder(). + Route("DELETE /zones/123123/recordsets/321321", + servermock.ResponseFromFixture("zones-recordsets_DELETE.json")). + Build(t) + + err := client.DeleteRecordSet(context.Background(), "123123", "321321") + require.NoError(t, err) +} diff --git a/providers/dns/otc/internal/fixtures/zones-recordsets_POST-request.json b/providers/dns/otc/internal/fixtures/zones-recordsets_POST-request.json new file mode 100644 index 000000000..41cab72a8 --- /dev/null +++ b/providers/dns/otc/internal/fixtures/zones-recordsets_POST-request.json @@ -0,0 +1,9 @@ +{ + "name": "_acme-challenge.example.com.", + "description": "Added TXT record for ACME dns-01 challenge using lego client", + "type": "TXT", + "ttl": 300, + "records": [ + "\"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY\"" + ] +} diff --git a/providers/dns/otc/internal/identity.go b/providers/dns/otc/internal/identity.go index f9e7cb08f..154ec65e2 100644 --- a/providers/dns/otc/internal/identity.go +++ b/providers/dns/otc/internal/identity.go @@ -46,6 +46,7 @@ func (c *Client) Login(ctx context.Context) error { c.muToken.Lock() defer c.muToken.Unlock() + c.token = token if c.token == "" { @@ -96,6 +97,7 @@ func (c *Client) obtainUserToken(ctx context.Context, payload LoginRequest) (*To } var newToken TokenResponse + err = json.Unmarshal(raw, &newToken) if err != nil { return nil, "", errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) @@ -106,6 +108,7 @@ func (c *Client) obtainUserToken(ctx context.Context, payload LoginRequest) (*To func getBaseURL(tokenResp *TokenResponse) (*url.URL, error) { var endpoints []Endpoint + for _, v := range tokenResp.Token.Catalog { if v.Type == "dns" { endpoints = append(endpoints, v.Endpoints...) diff --git a/providers/dns/otc/internal/identity_test.go b/providers/dns/otc/internal/identity_test.go index 18627869a..4dce72afc 100644 --- a/providers/dns/otc/internal/identity_test.go +++ b/providers/dns/otc/internal/identity_test.go @@ -1,25 +1,36 @@ package internal import ( - "context" + "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestClient_Login(t *testing.T) { - mock := NewDNSServerMock(t) - mock.HandleAuthSuccessfully() + var serverURL *url.URL - client := NewClient("user", "secret", "example.com", "test") - client.IdentityEndpoint, _ = url.JoinPath(mock.GetServerURL(), "/v3/auth/token") + client := servermock.NewBuilder( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret", "example.com", "test") + client.HTTPClient = server.Client() + client.IdentityEndpoint = server.URL + "/v3/auth/token" - err := client.Login(context.Background()) + serverURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ). + Route("POST /v3/auth/token", IdentityHandlerMock()). + Build(t) + + err := client.Login(t.Context()) require.NoError(t, err) - serverURL, _ := url.Parse(mock.GetServerURL()) assert.Equal(t, serverURL.JoinPath("v2").String(), client.baseURL.String()) assert.Equal(t, fakeOTCToken, client.token) } diff --git a/providers/dns/otc/internal/mock.go b/providers/dns/otc/internal/mock.go index 2ed7f84de..46da61e4c 100644 --- a/providers/dns/otc/internal/mock.go +++ b/providers/dns/otc/internal/mock.go @@ -2,62 +2,13 @@ package internal import ( "fmt" - "io" "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) const fakeOTCToken = "62244bc21da68d03ebac94e6636ff01f" -func writeFixture(rw http.ResponseWriter, filename string) { - file, err := os.Open(filepath.Join("internal", "fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) -} - -// DNSServerMock mock. -type DNSServerMock struct { - t *testing.T - server *httptest.Server - mux *http.ServeMux -} - -// NewDNSServerMock create a new DNSServerMock. -func NewDNSServerMock(t *testing.T) *DNSServerMock { - t.Helper() - - mux := http.NewServeMux() - - return &DNSServerMock{ - t: t, - server: httptest.NewServer(mux), - mux: mux, - } -} - -func (m *DNSServerMock) GetServerURL() string { - return m.server.URL -} - -// ShutdownServer creates the mock server. -func (m *DNSServerMock) ShutdownServer() { - m.server.Close() -} - -// HandleAuthSuccessfully Handle auth successfully. -func (m *DNSServerMock) HandleAuthSuccessfully() { - m.mux.HandleFunc("/v3/auth/token", func(w http.ResponseWriter, _ *http.Request) { +func IdentityHandlerMock() http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { w.Header().Set("X-Subject-Token", fakeOTCToken) _, _ = fmt.Fprintf(w, `{ @@ -69,7 +20,7 @@ func (m *DNSServerMock) HandleAuthSuccessfully() { "name": "", "endpoints": [ { - "url": "%s", + "url": "http://%s", "region": "eu-de", "region_id": "eu-de", "interface": "public", @@ -78,87 +29,6 @@ func (m *DNSServerMock) HandleAuthSuccessfully() { ] } ] - }}`, m.server.URL) - }) -} - -// HandleListZonesSuccessfully Handle list zones successfully. -func (m *DNSServerMock) HandleListZonesSuccessfully() { - m.mux.HandleFunc("/v2/zones", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(m.t, http.MethodGet, r.Method) - assert.Equal(m.t, "/v2/zones", r.URL.Path) - assert.Equal(m.t, "name=example.com.", r.URL.RawQuery) - assert.Equal(m.t, "application/json", r.Header.Get("Accept")) - - writeFixture(w, "zones_GET.json") - }) -} - -// HandleListZonesEmpty Handle list zones empty. -func (m *DNSServerMock) HandleListZonesEmpty() { - m.mux.HandleFunc("/v2/zones", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(m.t, http.MethodGet, r.Method) - assert.Equal(m.t, "/v2/zones", r.URL.Path) - assert.Equal(m.t, "name=example.com.", r.URL.RawQuery) - assert.Equal(m.t, "application/json", r.Header.Get("Accept")) - - writeFixture(w, "zones_GET_empty.json") - }) -} - -// HandleDeleteRecordsetsSuccessfully Handle delete recordsets successfully. -func (m *DNSServerMock) HandleDeleteRecordsetsSuccessfully() { - m.mux.HandleFunc("/v2/zones/123123/recordsets/321321", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(m.t, http.MethodDelete, r.Method) - assert.Equal(m.t, "/v2/zones/123123/recordsets/321321", r.URL.Path) - assert.Equal(m.t, "application/json", r.Header.Get("Accept")) - - writeFixture(w, "zones-recordsets_DELETE.json") - }) -} - -// HandleListRecordsetsEmpty Handle list recordsets empty. -func (m *DNSServerMock) HandleListRecordsetsEmpty() { - m.mux.HandleFunc("/v2/zones/123123/recordsets", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(m.t, "/v2/zones/123123/recordsets", r.URL.Path) - assert.Equal(m.t, "name=_acme-challenge.example.com.&type=TXT", r.URL.RawQuery) - - writeFixture(w, "zones-recordsets_GET_empty.json") - }) -} - -// HandleListRecordsetsSuccessfully Handle list recordsets successfully. -func (m *DNSServerMock) HandleListRecordsetsSuccessfully() { - m.mux.HandleFunc("/v2/zones/123123/recordsets", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(m.t, "application/json", r.Header.Get("Accept")) - - if r.Method == http.MethodGet { - assert.Equal(m.t, "/v2/zones/123123/recordsets", r.URL.Path) - assert.Equal(m.t, "name=_acme-challenge.example.com.&type=TXT", r.URL.RawQuery) - - writeFixture(w, "zones-recordsets_GET.json") - return - } - - if r.Method == http.MethodPost { - assert.Equal(m.t, "application/json", r.Header.Get("Content-Type")) - - raw, err := io.ReadAll(r.Body) - require.NoError(m.t, err) - exceptedString := `{ - "name": "_acme-challenge.example.com.", - "description": "Added TXT record for ACME dns-01 challenge using lego client", - "type": "TXT", - "ttl": 300, - "records": ["\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\""] - }` - - assert.JSONEq(m.t, exceptedString, string(raw)) - - writeFixture(w, "zones-recordsets_POST.json") - return - } - - http.Error(w, fmt.Sprintf("Expected method to be 'GET' or 'POST' but got '%s'", r.Method), http.StatusBadRequest) - }) + }}`, req.Context().Value(http.LocalAddrContextKey)) + } } diff --git a/providers/dns/otc/internal/types.go b/providers/dns/otc/internal/types.go index 38da4f110..e7bfe8fcb 100644 --- a/providers/dns/otc/internal/types.go +++ b/providers/dns/otc/internal/types.go @@ -41,8 +41,8 @@ type TokenResponse struct { } type Token struct { - User UserR `json:"user,omitempty"` - Domain Domain `json:"domain,omitempty"` + User UserR `json:"user"` + Domain Domain `json:"domain"` Catalog []Catalog `json:"catalog,omitempty"` Methods []string `json:"methods,omitempty"` Roles []Role `json:"roles,omitempty"` @@ -59,7 +59,7 @@ type Catalog struct { type UserR struct { ID string `json:"id,omitempty"` - Domain Domain `json:"domain,omitempty"` + Domain Domain `json:"domain"` Name string `json:"name,omitempty"` PasswordExpiresAt string `json:"password_expires_at,omitempty"` } @@ -106,7 +106,7 @@ type RecordSets struct { // ZonesResponse type ZonesResponse struct { - Links Links `json:"links,omitempty"` + Links Links `json:"links"` Zones []Zone `json:"zones"` Metadata Metadata `json:"metadata"` } diff --git a/providers/dns/otc/otc.go b/providers/dns/otc/otc.go index f2526b87e..65b362124 100644 --- a/providers/dns/otc/otc.go +++ b/providers/dns/otc/otc.go @@ -5,20 +5,16 @@ import ( "context" "errors" "fmt" - "net" "net/http" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/otc/internal" ) -const defaultIdentityEndpoint = "https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens" - -// minTTL 300 is otc minimum value for TTL. -const minTTL = 300 - // Environment variables names. const ( envNamespace = "OTC_" @@ -28,6 +24,7 @@ const ( EnvPassword = envNamespace + "PASSWORD" EnvProjectName = envNamespace + "PROJECT_NAME" EnvIdentityEndpoint = envNamespace + "IDENTITY_ENDPOINT" + EnvPrivateZone = envNamespace + "PRIVATE_ZONE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" @@ -36,13 +33,22 @@ const ( EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) +const defaultIdentityEndpoint = "https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens" + +// minTTL 300 is otc minimum value for TTL. +const minTTL = 300 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { - IdentityEndpoint string - DomainName string - ProjectName string - UserName string - Password string + DomainName string + ProjectName string + UserName string + Password string + IdentityEndpoint string + PrivateZone bool + PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration @@ -52,28 +58,27 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { + tr := &http.Transport{} + + defaultTransport, ok := http.DefaultTransport.(*http.Transport) + if ok { + tr = defaultTransport.Clone() + } + + // Workaround for keep alive bug in otc api + tr.DisableKeepAlives = true + return &Config{ + PrivateZone: env.GetOrDefaultBool(EnvPrivateZone, false), + IdentityEndpoint: env.GetOrDefaultString(EnvIdentityEndpoint, defaultIdentityEndpoint), + TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), - IdentityEndpoint: env.GetOrDefaultString(EnvIdentityEndpoint, defaultIdentityEndpoint), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ - Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, - MaxIdleConns: 100, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - - // Workaround for keep alive bug in otc api - DisableKeepAlives: true, - }, + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), + Transport: tr, }, } } @@ -126,6 +131,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } @@ -145,7 +152,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("otc: %w", err) } - zoneID, err := d.client.GetZoneID(ctx, authZone) + zoneID, err := d.client.GetZoneID(ctx, authZone, d.config.PrivateZone) if err != nil { return fmt.Errorf("otc: unable to get zone: %w", err) } @@ -182,7 +189,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("otc: %w", err) } - zoneID, err := d.client.GetZoneID(ctx, authZone) + zoneID, err := d.client.GetZoneID(ctx, authZone, d.config.PrivateZone) if err != nil { return fmt.Errorf("otc: %w", err) } diff --git a/providers/dns/otc/otc.toml b/providers/dns/otc/otc.toml index e3c60158c..e63077fda 100644 --- a/providers/dns/otc/otc.toml +++ b/providers/dns/otc/otc.toml @@ -4,7 +4,13 @@ URL = "https://cloud.telekom.de/en" Code = "otc" Since = "v0.4.1" -Example = '''''' +Example = ''' +OTC_DOMAIN_NAME=domain_name \ +OTC_USER_NAME=user_name \ +OTC_PASSWORD=password \ +OTC_PROJECT_NAME=project_name \ +lego --dns otc -d '*.example.com' -d example.com run +''' [Configuration] [Configuration.Credentials] @@ -12,13 +18,14 @@ Example = '''''' OTC_PASSWORD = "Password" OTC_PROJECT_NAME = "Project name" OTC_DOMAIN_NAME = "Domain name" - OTC_IDENTITY_ENDPOINT = "Identity endpoint URL" [Configuration.Additional] - OTC_POLLING_INTERVAL = "Time between DNS propagation check" - OTC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - OTC_SEQUENCE_INTERVAL = "Time between sequential requests" - OTC_TTL = "The TTL of the TXT record used for the DNS challenge" - OTC_HTTP_TIMEOUT = "API request timeout" + OTC_IDENTITY_ENDPOINT = "Identity endpoint URL (default: https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens)" + OTC_PRIVATE_ZONE = "Set to true to use private zones only (default: use public zones only)" + OTC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + OTC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + OTC_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" + OTC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + OTC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://docs.otc.t-systems.com/domain-name-service/api-ref/index.html" diff --git a/providers/dns/otc/otc_test.go b/providers/dns/otc/otc_test.go index 54907b69e..518ce0f19 100644 --- a/providers/dns/otc/otc_test.go +++ b/providers/dns/otc/otc_test.go @@ -2,129 +2,334 @@ package otc import ( "fmt" - "os" + "net/http/httptest" "testing" + "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/otc/internal" - "github.com/stretchr/testify/suite" + "github.com/stretchr/testify/require" ) -type OTCSuite struct { - suite.Suite +const envDomain = envNamespace + "DOMAIN" - mock *internal.DNSServerMock - envTest *tester.EnvTest +var envTest = tester.NewEnvTest( + EnvDomainName, + EnvUserName, + EnvPassword, + EnvPrivateZone, + EnvProjectName, + EnvIdentityEndpoint). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvDomainName: "example.com", + EnvUserName: "user", + EnvPassword: "secret", + EnvProjectName: "test", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvDomainName: "", + EnvUserName: "", + EnvPassword: "", + EnvProjectName: "", + }, + expected: "otc: some credentials information are missing: OTC_DOMAIN_NAME,OTC_USER_NAME,OTC_PASSWORD,OTC_PROJECT_NAME", + }, + { + desc: "missing domain name", + envVars: map[string]string{ + EnvDomainName: "", + EnvUserName: "user", + EnvPassword: "secret", + EnvProjectName: "test", + }, + expected: "otc: some credentials information are missing: OTC_DOMAIN_NAME", + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvDomainName: "example.com", + EnvUserName: "", + EnvPassword: "secret", + EnvProjectName: "test", + }, + expected: "otc: some credentials information are missing: OTC_USER_NAME", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvDomainName: "example.com", + EnvUserName: "user", + EnvPassword: "", + EnvProjectName: "test", + }, + expected: "otc: some credentials information are missing: OTC_PASSWORD", + }, + { + desc: "missing project name", + envVars: map[string]string{ + EnvDomainName: "example.com", + EnvUserName: "user", + EnvPassword: "secret", + EnvProjectName: "", + }, + expected: "otc: some credentials information are missing: OTC_PROJECT_NAME", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } } -func (s *OTCSuite) SetupTest() { - s.mock = internal.NewDNSServerMock(s.T()) - s.mock.HandleAuthSuccessfully() - s.envTest = tester.NewEnvTest( - EnvDomainName, - EnvUserName, - EnvPassword, - EnvProjectName, - EnvIdentityEndpoint, - ) +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + domainName string + projectName string + username string + password string + expected string + }{ + { + desc: "success", + domainName: "example.com", + projectName: "test", + username: "user", + password: "secret", + }, + { + desc: "missing credentials", + expected: "otc: credentials missing", + }, + { + desc: "missing domain name", + domainName: "", + projectName: "test", + username: "user", + password: "secret", + expected: "otc: credentials missing", + }, + { + desc: "missing project name", + domainName: "example.com", + projectName: "", + username: "user", + password: "secret", + expected: "otc: credentials missing", + }, + { + desc: "missing username", + domainName: "example.com", + projectName: "test", + username: "", + password: "secret", + expected: "otc: credentials missing", + }, + { + desc: "missing password ", + domainName: "example.com", + projectName: "test", + username: "user", + password: "", + expected: "otc: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.DomainName = test.domainName + config.ProjectName = test.projectName + config.UserName = test.username + config.Password = test.password + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } } -func (s *OTCSuite) TearDownTest() { - s.envTest.RestoreEnv() - s.mock.ShutdownServer() -} +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } -func TestTestSuite(t *testing.T) { - suite.Run(t, new(OTCSuite)) -} - -func (s *OTCSuite) createDNSProvider() (*DNSProvider, error) { - config := NewDefaultConfig() - config.UserName = "UserName" - config.Password = "Password" - config.DomainName = "DomainName" - config.ProjectName = "ProjectName" - config.IdentityEndpoint = fmt.Sprintf("%s/v3/auth/token", s.mock.GetServerURL()) - - return NewDNSProviderConfig(config) -} - -func (s *OTCSuite) TestLoginEnv() { - s.envTest.ClearEnv() - - s.envTest.Apply(map[string]string{ - EnvDomainName: "unittest1", - EnvUserName: "unittest2", - EnvPassword: "unittest3", - EnvProjectName: "unittest4", - EnvIdentityEndpoint: "unittest5", - }) + envTest.RestoreEnv() provider, err := NewDNSProvider() - s.Require().NoError(err) + require.NoError(t, err) - s.Equal("unittest1", provider.config.DomainName) - s.Equal("unittest2", provider.config.UserName) - s.Equal("unittest3", provider.config.Password) - s.Equal("unittest4", provider.config.ProjectName) - s.Equal("unittest5", provider.config.IdentityEndpoint) - - os.Setenv(EnvIdentityEndpoint, "") - - provider, err = NewDNSProvider() - s.Require().NoError(err) - - s.Equal("https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens", provider.config.IdentityEndpoint) + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) } -func (s *OTCSuite) TestLoginEnvEmpty() { - s.envTest.ClearEnv() +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } - _, err := NewDNSProvider() - s.EqualError(err, "otc: some credentials information are missing: OTC_DOMAIN_NAME,OTC_USER_NAME,OTC_PASSWORD,OTC_PROJECT_NAME") + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + time.Sleep(1 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) } -func (s *OTCSuite) TestDNSProvider_Present() { - s.mock.HandleListZonesSuccessfully() - s.mock.HandleListRecordsetsSuccessfully() +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(false). + Route("GET /v2/zones", + servermock.ResponseFromInternal("zones_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com.")). + Route("POST /v2/zones/123123/recordsets", + servermock.Noop(), + servermock.CheckRequestJSONBodyFromInternal("zones-recordsets_POST-request.json")). + Build(t) - provider, err := s.createDNSProvider() - s.Require().NoError(err) - - err = provider.Present("example.com", "", "foobar") - s.Require().NoError(err) + err := provider.Present("example.com", "", "123d==") + require.NoError(t, err) } -func (s *OTCSuite) TestDNSProvider_Present_EmptyZone() { - s.mock.HandleListZonesEmpty() - s.mock.HandleListRecordsetsSuccessfully() +func TestDNSProvider_Present_private(t *testing.T) { + provider := mockBuilder(true). + Route("GET /v2/zones", + servermock.ResponseFromInternal("zones_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com."). + With("type", "private")). + Route("POST /v2/zones/123123/recordsets", + servermock.Noop(), + servermock.CheckRequestJSONBodyFromInternal("zones-recordsets_POST-request.json")). + Build(t) - provider, err := s.createDNSProvider() - s.Require().NoError(err) - - err = provider.Present("example.com", "", "foobar") - s.Error(err) + err := provider.Present("example.com", "", "123d==") + require.NoError(t, err) } -func (s *OTCSuite) TestDNSProvider_CleanUp() { - s.mock.HandleListZonesSuccessfully() - s.mock.HandleListRecordsetsSuccessfully() - s.mock.HandleDeleteRecordsetsSuccessfully() +func TestDNSProvider_Present_emptyZone(t *testing.T) { + provider := mockBuilder(false). + Route("GET /v2/zones", + servermock.ResponseFromInternal("zones_GET_empty.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com.")). + Build(t) - provider, err := s.createDNSProvider() - s.Require().NoError(err) - - err = provider.CleanUp("example.com", "", "foobar") - s.Require().NoError(err) + err := provider.Present("example.com", "", "123d==") + require.EqualError(t, err, "otc: unable to get zone: zone example.com. not found") } -func (s *OTCSuite) TestDNSProvider_CleanUp_EmptyRecordset() { - s.mock.HandleListZonesSuccessfully() - s.mock.HandleListRecordsetsEmpty() +func TestDNSProvider_Cleanup(t *testing.T) { + provider := mockBuilder(false). + Route("GET /v2/zones", + servermock.ResponseFromInternal("zones_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com.")). + Route("GET /v2/zones/123123/recordsets", + servermock.ResponseFromInternal("zones-recordsets_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge.example.com."). + With("type", "TXT")). + Route("DELETE /v2/zones/123123/recordsets/321321", + servermock.ResponseFromInternal("zones-recordsets_DELETE.json")). + Build(t) - provider, err := s.createDNSProvider() - s.Require().NoError(err) - - err = provider.CleanUp("example.com", "", "foobar") - s.Require().Error(err) + err := provider.CleanUp("example.com", "", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_Cleanup_private(t *testing.T) { + provider := mockBuilder(true). + Route("GET /v2/zones", + servermock.ResponseFromInternal("zones_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com."). + With("type", "private")). + Route("GET /v2/zones/123123/recordsets", + servermock.ResponseFromInternal("zones-recordsets_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge.example.com."). + With("type", "TXT")). + Route("DELETE /v2/zones/123123/recordsets/321321", + servermock.ResponseFromInternal("zones-recordsets_DELETE.json")). + Build(t) + + err := provider.CleanUp("example.com", "", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_Cleanup_emptyRecordset(t *testing.T) { + provider := mockBuilder(false). + Route("GET /v2/zones", + servermock.ResponseFromInternal("zones_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com.")). + Route("GET /v2/zones/123123/recordsets", + servermock.ResponseFromInternal("zones-recordsets_GET_empty.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge.example.com."). + With("type", "TXT")). + Build(t) + + err := provider.CleanUp("example.com", "", "123d==") + require.EqualError(t, err, "otc: unable to get record _acme-challenge.example.com. for zone example.com: record not found") +} + +func mockBuilder(private bool) *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.HTTPClient = server.Client() + config.UserName = "user" + config.Password = "secret" + config.DomainName = "example.com" + config.ProjectName = "test" + config.IdentityEndpoint = fmt.Sprintf("%s/v3/auth/token", server.URL) + config.PrivateZone = private + + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader().WithJSONHeaders(), + ). + Route("POST /v3/auth/token", internal.IdentityHandlerMock()) } diff --git a/providers/dns/ovh/ovh.go b/providers/dns/ovh/ovh.go index c86d6129a..a8d12d819 100644 --- a/providers/dns/ovh/ovh.go +++ b/providers/dns/ovh/ovh.go @@ -8,8 +8,10 @@ import ( "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/useragent" "github.com/ovh/go-ovh/ovh" ) @@ -46,6 +48,8 @@ const ( // EnvAccessToken Authenticate using Access Token client. const EnvAccessToken = envNamespace + "ACCESS_TOKEN" +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Record a DNS record. type Record struct { ID int64 `json:"id,omitempty"` @@ -80,10 +84,6 @@ type Config struct { HTTPClient *http.Client } -func (c *Config) hasAppKeyAuth() bool { - return c.ApplicationKey != "" || c.ApplicationSecret != "" || c.ConsumerKey != "" -} - // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ @@ -96,10 +96,15 @@ func NewDefaultConfig() *Config { } } +func (c *Config) hasAppKeyAuth() bool { + return c.ApplicationKey != "" || c.ApplicationSecret != "" || c.ConsumerKey != "" +} + // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { - config *Config - client *ovh.Client + config *Config + client *ovh.Client + recordIDs map[string]int64 recordIDsMu sync.Mutex } @@ -187,6 +192,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // Create TXT record var respData Record + err = d.client.Post(reqURL, reqData, &respData) if err != nil { return fmt.Errorf("ovh: error when call api to add record (%s): %w", reqURL, err) @@ -194,6 +200,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // Apply the change reqURL = fmt.Sprintf("/domain/zone/%s/refresh", authZone) + err = d.client.Post(reqURL, nil, nil) if err != nil { return fmt.Errorf("ovh: error when call api to refresh zone (%s): %w", reqURL, err) @@ -214,6 +221,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("ovh: unknown record ID for '%s'", info.EffectiveFQDN) } @@ -234,6 +242,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { // Apply the change reqURL = fmt.Sprintf("/domain/zone/%s/refresh", authZone) + err = d.client.Post(reqURL, nil, nil) if err != nil { return fmt.Errorf("ovh: error when call api to refresh zone (%s): %w", reqURL, err) @@ -254,8 +263,10 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { } func newClient(config *Config) (*ovh.Client, error) { - var client *ovh.Client - var err error + var ( + client *ovh.Client + err error + ) switch { case config.hasAppKeyAuth(): @@ -274,5 +285,11 @@ func newClient(config *Config) (*ovh.Client, error) { client.UserAgent = useragent.Get() + if config.HTTPClient != nil { + client.Client = config.HTTPClient + } + + client.Client = clientdebug.Wrap(client.Client) + return client, nil } diff --git a/providers/dns/ovh/ovh.toml b/providers/dns/ovh/ovh.toml index cbdcb43ae..abf22bd7a 100644 --- a/providers/dns/ovh/ovh.toml +++ b/providers/dns/ovh/ovh.toml @@ -11,20 +11,20 @@ OVH_APPLICATION_KEY=1234567898765432 \ OVH_APPLICATION_SECRET=b9841238feb177a84330febba8a832089 \ OVH_CONSUMER_KEY=256vfsd347245sdfg \ OVH_ENDPOINT=ovh-eu \ -lego --email you@example.com --dns ovh -d '*.example.com' -d example.com run +lego --dns ovh -d '*.example.com' -d example.com run # Or Access Token: OVH_ACCESS_TOKEN=xxx \ OVH_ENDPOINT=ovh-eu \ -lego --email you@example.com --dns ovh -d '*.example.com' -d example.com run +lego --dns ovh -d '*.example.com' -d example.com run # Or OAuth2: OVH_CLIENT_ID=yyy \ OVH_CLIENT_SECRET=xxx \ OVH_ENDPOINT=ovh-eu \ -lego --email you@example.com --dns ovh -d '*.example.com' -d example.com run +lego --dns ovh -d '*.example.com' -d example.com run ''' Additional = ''' @@ -76,10 +76,10 @@ Both authentication methods cannot be used at the same time. OVH_CLIENT_SECRET = "Client secret (OAuth2)" OVH_ACCESS_TOKEN = "Access token" [Configuration.Additional] - OVH_POLLING_INTERVAL = "Time between DNS propagation check" - OVH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - OVH_TTL = "The TTL of the TXT record used for the DNS challenge" - OVH_HTTP_TIMEOUT = "API request timeout" + OVH_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + OVH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + OVH_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + OVH_HTTP_TIMEOUT = "API request timeout in seconds (Default: 180)" [Links] API = "https://eu.api.ovh.com/" diff --git a/providers/dns/ovh/ovh_test.go b/providers/dns/ovh/ovh_test.go index f070f2e85..332e7f192 100644 --- a/providers/dns/ovh/ovh_test.go +++ b/providers/dns/ovh/ovh_test.go @@ -162,6 +162,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -201,12 +202,11 @@ func TestNewDNSProviderConfig(t *testing.T) { consumerKey: "D", }, { - desc: "application key: missing api endpoint", + desc: "application key: default api endpoint", apiEndpoint: "", applicationKey: "B", applicationSecret: "C", consumerKey: "D", - expected: "ovh: new client: unknown endpoint '', consider checking 'Endpoints' list or using an URL", }, { desc: "application key: invalid api endpoint", @@ -239,11 +239,10 @@ func TestNewDNSProviderConfig(t *testing.T) { clientSecret: "C", }, { - desc: "oauth2: missing api endpoint", + desc: "oauth2: default api endpoint", apiEndpoint: "", clientID: "B", clientSecret: "C", - expected: "ovh: new client: unknown endpoint '', consider checking 'Endpoints' list or using an URL", }, { desc: "oauth2: invalid api endpoint", @@ -317,6 +316,7 @@ func TestNewDNSProviderConfig(t *testing.T) { // The OVH client use the same env vars than lego, so it requires to clean them. defer envTest.RestoreEnv() + envTest.ClearEnv() for _, test := range testCases { @@ -356,6 +356,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -369,6 +370,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/pdns/internal/client.go b/providers/dns/pdns/internal/client.go index bc525c578..f72dd4d78 100644 --- a/providers/dns/pdns/internal/client.go +++ b/providers/dns/pdns/internal/client.go @@ -18,6 +18,9 @@ import ( "github.com/miekg/dns" ) +// APIKeyHeader API key header. +const APIKeyHeader = "X-Api-Key" + // Client the PowerDNS API client. type Client struct { serverName string @@ -66,6 +69,7 @@ func (c *Client) getAPIVersion(ctx context.Context) (int, error) { } var versions []apiVersion + err = json.Unmarshal(result, &versions) if err != nil { return 0, err @@ -95,6 +99,7 @@ func (c *Client) GetHostedZone(ctx context.Context, authZone string) (*HostedZon } var zone HostedZone + err = json.Unmarshal(result, &zone) if err != nil { return nil, err @@ -163,7 +168,7 @@ func (c *Client) joinPath(elem ...string) *url.URL { } func (c *Client) do(req *http.Request) (json.RawMessage, error) { - req.Header.Set("X-API-Key", c.apiKey) + req.Header.Set(APIKeyHeader, c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { @@ -177,6 +182,7 @@ func (c *Client) do(req *http.Request) (json.RawMessage, error) { } var msg json.RawMessage + err = json.NewDecoder(resp.Body).Decode(&msg) if err != nil { if errors.Is(err, io.EOF) { @@ -190,10 +196,12 @@ func (c *Client) do(req *http.Request) (json.RawMessage, error) { // check for PowerDNS error message if len(msg) > 0 && msg[0] == '{' { var errInfo apiError + err = json.Unmarshal(msg, &errInfo) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, msg, err) } + if errInfo.ShortMsg != "" { return nil, fmt.Errorf("error talking to PDNS API: %w", errInfo) } diff --git a/providers/dns/pdns/internal/client_test.go b/providers/dns/pdns/internal/client_test.go index b0eb9d2ed..17f05095f 100644 --- a/providers/dns/pdns/internal/client_test.go +++ b/providers/dns/pdns/internal/client_test.go @@ -1,66 +1,27 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + serverURL, _ := url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client := NewClient(serverURL, "server", 0, "secret") + client.HTTPClient = server.Client() - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - apiKey := req.Header.Get("X-API-Key") - if apiKey != "secret" { - http.Error(rw, fmt.Sprintf("invalid credentials: %s", apiKey), http.StatusBadRequest) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - serverURL, _ := url.Parse(server.URL) - - client := NewClient(serverURL, "server", 0, "secret") - client.HTTPClient = server.Client() - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders().With(APIKeyHeader, "secret")) } func TestClient_joinPath(t *testing.T) { @@ -160,10 +121,14 @@ func TestClient_joinPath(t *testing.T) { } func TestClient_GetHostedZone(t *testing.T) { - client := setupTest(t, http.MethodGet, "/api/v1/servers/server/zones/example.org.", http.StatusOK, "zone.json") + client := mockBuilder(). + Route("GET /api/v1/servers/server/zones/example.org.", + servermock.ResponseFromFixture("zone.json")). + Build(t) + client.apiVersion = 1 - zone, err := client.GetHostedZone(context.Background(), "example.org.") + zone, err := client.GetHostedZone(t.Context(), "example.org.") require.NoError(t, err) expected := &HostedZone{ @@ -203,18 +168,27 @@ func TestClient_GetHostedZone(t *testing.T) { } func TestClient_GetHostedZone_error(t *testing.T) { - client := setupTest(t, http.MethodGet, "/api/v1/servers/server/zones/example.org.", http.StatusUnprocessableEntity, "error.json") + client := mockBuilder(). + Route("GET /api/v1/servers/server/zones/example.org.", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnprocessableEntity)). + Build(t) + client.apiVersion = 1 - _, err := client.GetHostedZone(context.Background(), "example.org.") + _, err := client.GetHostedZone(t.Context(), "example.org.") require.ErrorAs(t, err, &apiError{}) } func TestClient_GetHostedZone_v0(t *testing.T) { - client := setupTest(t, http.MethodGet, "/servers/server/zones/example.org.", http.StatusOK, "zone.json") + client := mockBuilder(). + Route("GET /servers/server/zones/example.org.", + servermock.ResponseFromFixture("zone.json")). + Build(t) + client.apiVersion = 0 - zone, err := client.GetHostedZone(context.Background(), "example.org.") + zone, err := client.GetHostedZone(t.Context(), "example.org.") require.NoError(t, err) expected := &HostedZone{ @@ -254,7 +228,12 @@ func TestClient_GetHostedZone_v0(t *testing.T) { } func TestClient_UpdateRecords(t *testing.T) { - client := setupTest(t, http.MethodPatch, "/api/v1/servers/localhost/zones/example.org.", http.StatusOK, "zone.json") + client := mockBuilder(). + Route("PATCH /api/v1/servers/localhost/zones/example.org.", + servermock.ResponseFromFixture("zone.json"), + servermock.CheckRequestJSONBodyFromFixture("zone-request.json")). + Build(t) + client.apiVersion = 1 client.serverName = "localhost" @@ -279,12 +258,17 @@ func TestClient_UpdateRecords(t *testing.T) { }}, } - err := client.UpdateRecords(context.Background(), zone, rrSets) + err := client.UpdateRecords(t.Context(), zone, rrSets) require.NoError(t, err) } func TestClient_UpdateRecords_NonRootApi(t *testing.T) { - client := setupTest(t, http.MethodPatch, "/some/path/api/v1/servers/localhost/zones/example.org.", http.StatusOK, "zone.json") + client := mockBuilder(). + Route("PATCH /some/path/api/v1/servers/localhost/zones/example.org.", + servermock.ResponseFromFixture("zone.json"), + servermock.CheckRequestJSONBodyFromFixture("zone-request.json")). + Build(t) + client.Host = client.Host.JoinPath("some", "path") client.apiVersion = 1 client.serverName = "localhost" @@ -310,12 +294,17 @@ func TestClient_UpdateRecords_NonRootApi(t *testing.T) { }}, } - err := client.UpdateRecords(context.Background(), zone, rrSets) + err := client.UpdateRecords(t.Context(), zone, rrSets) require.NoError(t, err) } func TestClient_UpdateRecords_v0(t *testing.T) { - client := setupTest(t, http.MethodPatch, "/servers/localhost/zones/example.org.", http.StatusOK, "zone.json") + client := mockBuilder(). + Route("PATCH /servers/localhost/zones/example.org.", + servermock.ResponseFromFixture("zone.json"), + servermock.CheckRequestJSONBodyFromFixture("zone-request.json")). + Build(t) + client.apiVersion = 0 client.serverName = "localhost" @@ -340,12 +329,15 @@ func TestClient_UpdateRecords_v0(t *testing.T) { }}, } - err := client.UpdateRecords(context.Background(), zone, rrSets) + err := client.UpdateRecords(t.Context(), zone, rrSets) require.NoError(t, err) } func TestClient_Notify(t *testing.T) { - client := setupTest(t, http.MethodPut, "/api/v1/servers/localhost/zones/example.org./notify", http.StatusOK, "") + client := mockBuilder(). + Route("PUT /api/v1/servers/localhost/zones/example.org./notify", nil). + Build(t) + client.apiVersion = 1 client.serverName = "localhost" @@ -356,12 +348,15 @@ func TestClient_Notify(t *testing.T) { Kind: "Master", } - err := client.Notify(context.Background(), zone) + err := client.Notify(t.Context(), zone) require.NoError(t, err) } func TestClient_Notify_NonRootApi(t *testing.T) { - client := setupTest(t, http.MethodPut, "/some/path/api/v1/servers/localhost/zones/example.org./notify", http.StatusOK, "") + client := mockBuilder(). + Route("PUT /some/path/api/v1/servers/localhost/zones/example.org./notify", nil). + Build(t) + client.Host = client.Host.JoinPath("some", "path") client.apiVersion = 1 client.serverName = "localhost" @@ -373,12 +368,15 @@ func TestClient_Notify_NonRootApi(t *testing.T) { Kind: "Master", } - err := client.Notify(context.Background(), zone) + err := client.Notify(t.Context(), zone) require.NoError(t, err) } func TestClient_Notify_v0(t *testing.T) { - client := setupTest(t, http.MethodPut, "/api/v1/servers/localhost/zones/example.org./notify", http.StatusOK, "") + client := mockBuilder(). + Route("PUT /some/path/api/v1/servers/localhost/zones/example.org./notify", nil). + Build(t) + client.apiVersion = 0 zone := &HostedZone{ @@ -388,14 +386,17 @@ func TestClient_Notify_v0(t *testing.T) { Kind: "Master", } - err := client.Notify(context.Background(), zone) + err := client.Notify(t.Context(), zone) require.NoError(t, err) } func TestClient_getAPIVersion(t *testing.T) { - client := setupTest(t, http.MethodGet, "/api", http.StatusOK, "versions.json") + client := mockBuilder(). + Route("GET /api", + servermock.ResponseFromFixture("versions.json")). + Build(t) - version, err := client.getAPIVersion(context.Background()) + version, err := client.getAPIVersion(t.Context()) require.NoError(t, err) assert.Equal(t, 4, version) diff --git a/providers/dns/pdns/internal/fixtures/zone-request.json b/providers/dns/pdns/internal/fixtures/zone-request.json new file mode 100644 index 000000000..5e4a6d2b9 --- /dev/null +++ b/providers/dns/pdns/internal/fixtures/zone-request.json @@ -0,0 +1,19 @@ +{ + "rrsets": [ + { + "name": "example.org.", + "type": "NS", + "kind": "", + "changetype": "REPLACE", + "records": [ + { + "content": "192.0.2.5", + "disabled": false, + "name": "ns1.example.org.", + "type": "A", + "ttl": 86400 + } + ] + } + ] +} diff --git a/providers/dns/pdns/pdns.go b/providers/dns/pdns/pdns.go index 751501b75..e7ead7078 100644 --- a/providers/dns/pdns/pdns.go +++ b/providers/dns/pdns/pdns.go @@ -7,11 +7,14 @@ import ( "fmt" "net/http" "net/url" + "strconv" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/pdns/internal" ) @@ -30,6 +33,8 @@ const ( EnvServerName = envNamespace + "SERVER_NAME" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -99,6 +104,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client := internal.NewClient(config.Host, config.ServerName, config.APIVersion, config.APIKey) + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + if config.APIVersion <= 0 { err := client.SetAPIVersion(context.Background()) if err != nil { @@ -117,6 +128,8 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) @@ -124,11 +137,9 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("pdns: could not find zone for domain %q: %w", domain, err) } - ctx := context.Background() - zone, err := d.client.GetHostedZone(ctx, authZone) if err != nil { - return fmt.Errorf("pdns: %w", err) + return fmt.Errorf("pdns: get hosted zone for %s: %w", authZone, err) } name := info.EffectiveFQDN @@ -140,45 +151,49 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // Look for existing records. existingRRSet := findTxtRecord(zone, info.EffectiveFQDN) - // merge the existing and new records var records []internal.Record if existingRRSet != nil { records = existingRRSet.Records } - rec := internal.Record{ - Content: "\"" + info.Value + "\"", + records = append(records, internal.Record{ + Content: strconv.Quote(info.Value), Disabled: false, // pre-v1 API Type: "TXT", Name: name, TTL: d.config.TTL, - } + }) rrSets := internal.RRSets{ - RRSets: []internal.RRSet{ - { - Name: name, - ChangeType: "REPLACE", - Type: "TXT", - Kind: "Master", - TTL: d.config.TTL, - Records: append(records, rec), - }, - }, + RRSets: []internal.RRSet{{ + Name: name, + ChangeType: "REPLACE", + Type: "TXT", + Kind: "Master", + TTL: d.config.TTL, + Records: records, + }}, } err = d.client.UpdateRecords(ctx, zone, rrSets) if err != nil { - return fmt.Errorf("pdns: %w", err) + return fmt.Errorf("pdns: update records: %w", err) } - return d.client.Notify(ctx, zone) + err = d.client.Notify(ctx, zone) + if err != nil { + return fmt.Errorf("pdns: notify: %w", err) + } + + return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) @@ -186,35 +201,49 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("pdns: could not find zone for domain %q: %w", domain, err) } - ctx := context.Background() - zone, err := d.client.GetHostedZone(ctx, authZone) if err != nil { - return fmt.Errorf("pdns: %w", err) + return fmt.Errorf("pdns: get hosted zone for %s: %w", authZone, err) } + // Look for existing records. set := findTxtRecord(zone, info.EffectiveFQDN) - if set == nil { return fmt.Errorf("pdns: no existing record found for %s", info.EffectiveFQDN) } - rrSets := internal.RRSets{ - RRSets: []internal.RRSet{ - { - Name: set.Name, - Type: set.Type, - ChangeType: "DELETE", - }, - }, + var records []internal.Record + + for _, r := range set.Records { + if r.Content != strconv.Quote(info.Value) { + records = append(records, r) + } } - err = d.client.UpdateRecords(ctx, zone, rrSets) + rrSet := internal.RRSet{ + Name: set.Name, + Type: set.Type, + } + + if len(records) > 0 { + rrSet.ChangeType = "REPLACE" + rrSet.TTL = d.config.TTL + rrSet.Records = records + } else { + rrSet.ChangeType = "DELETE" + } + + err = d.client.UpdateRecords(ctx, zone, internal.RRSets{RRSets: []internal.RRSet{rrSet}}) if err != nil { - return fmt.Errorf("pdns: %w", err) + return fmt.Errorf("pdns: update records: %w", err) } - return d.client.Notify(ctx, zone) + err = d.client.Notify(ctx, zone) + if err != nil { + return fmt.Errorf("pdns: notify: %w", err) + } + + return nil } func findTxtRecord(zone *internal.HostedZone, fqdn string) *internal.RRSet { diff --git a/providers/dns/pdns/pdns.toml b/providers/dns/pdns/pdns.toml index 81158c444..a83d80922 100644 --- a/providers/dns/pdns/pdns.toml +++ b/providers/dns/pdns/pdns.toml @@ -7,7 +7,7 @@ Since = "v0.4.0" Example = ''' PDNS_API_URL=http://pdns-server:80/ \ PDNS_API_KEY=xxxx \ -lego --email you@example.com --dns pdns -d '*.example.com' -d example.com run +lego --dns pdns -d '*.example.com' -d example.com run ''' Additional = ''' @@ -28,10 +28,10 @@ PowerDNS Notes: [Configuration.Additional] PDNS_SERVER_NAME = "Name of the server in the URL, 'localhost' by default" PDNS_API_VERSION = "Skip API version autodetection and use the provided version number." - PDNS_POLLING_INTERVAL = "Time between DNS propagation check" - PDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - PDNS_TTL = "The TTL of the TXT record used for the DNS challenge" - PDNS_HTTP_TIMEOUT = "API request timeout" + PDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + PDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + PDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + PDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://doc.powerdns.com/md/httpapi/README/" diff --git a/providers/dns/pdns/pdns_test.go b/providers/dns/pdns/pdns_test.go index 70b386b81..0213ba17c 100644 --- a/providers/dns/pdns/pdns_test.go +++ b/providers/dns/pdns/pdns_test.go @@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -136,14 +137,19 @@ func TestLivePresentAndCleanup(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) + err = provider.Present(envTest.GetDomain(), "", "123e==") + require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) + err = provider.CleanUp(envTest.GetDomain(), "", "123e==") + require.NoError(t, err) } func mustParse(rawURL string) *url.URL { @@ -151,5 +157,6 @@ func mustParse(rawURL string) *url.URL { if err != nil { panic(err) } + return u } diff --git a/providers/dns/plesk/internal/client.go b/providers/dns/plesk/internal/client.go index 9dd9d5ee3..47abba805 100644 --- a/providers/dns/plesk/internal/client.go +++ b/providers/dns/plesk/internal/client.go @@ -24,7 +24,7 @@ type Client struct { } // NewClient created a new Client. -func NewClient(baseURL *url.URL, login string, password string) *Client { +func NewClient(baseURL *url.URL, login, password string) *Client { return &Client{ login: login, password: password, @@ -35,7 +35,7 @@ func NewClient(baseURL *url.URL, login string, password string) *Client { // GetSite gets a site. // https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference/managing-sites-domains/getting-information-about-sites.66583/ -func (c Client) GetSite(ctx context.Context, domain string) (int, error) { +func (c *Client) GetSite(ctx context.Context, domain string) (int, error) { payload := RequestPacketType{Site: &SiteTypeRequest{Get: SiteGetRequest{Filter: &SiteFilterType{ Name: domain, }}}} @@ -62,7 +62,7 @@ func (c Client) GetSite(ctx context.Context, domain string) (int, error) { // AddRecord adds a TXT record. // https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference/managing-dns/managing-dns-records/adding-dns-record.34798/ -func (c Client) AddRecord(ctx context.Context, siteID int, host, value string) (int, error) { +func (c *Client) AddRecord(ctx context.Context, siteID int, host, value string) (int, error) { payload := RequestPacketType{DNS: &DNSInputType{AddRec: []AddRecRequest{{ SiteID: siteID, Type: "TXT", @@ -92,7 +92,7 @@ func (c Client) AddRecord(ctx context.Context, siteID int, host, value string) ( // DeleteRecord Deletes a TXT record. // https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference/managing-dns/managing-dns-records/deleting-dns-records.34864/ -func (c Client) DeleteRecord(ctx context.Context, recordID int) (int, error) { +func (c *Client) DeleteRecord(ctx context.Context, recordID int) (int, error) { payload := RequestPacketType{DNS: &DNSInputType{DelRec: []DelRecRequest{{Filter: DNSSelectionFilterType{ ID: recordID, }}}}} @@ -117,10 +117,11 @@ func (c Client) DeleteRecord(ctx context.Context, recordID int) (int, error) { return response.DNS.DelRec[0].Result.ID, nil } -func (c Client) doRequest(ctx context.Context, payload RequestPacketType) (*ResponsePacketType, error) { +func (c *Client) doRequest(ctx context.Context, payload RequestPacketType) (*ResponsePacketType, error) { endpoint := c.baseURL.JoinPath("/enterprise/control/agent.php") body := new(bytes.Buffer) + err := xml.NewEncoder(body).Encode(payload) if err != nil { return nil, err @@ -153,6 +154,7 @@ func (c Client) doRequest(ctx context.Context, payload RequestPacketType) (*Resp } var response ResponsePacketType + err = xml.Unmarshal(raw, &response) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) diff --git a/providers/dns/plesk/internal/client_test.go b/providers/dns/plesk/internal/client_test.go index 5d59a4c87..14cadd0e0 100644 --- a/providers/dns/plesk/internal/client_test.go +++ b/providers/dns/plesk/internal/client_test.go @@ -1,144 +1,125 @@ package internal import ( - "context" - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + serverURL, _ := url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client := NewClient(serverURL, "user", "secret") + client.HTTPClient = server.Client() - serverURL, err := url.Parse(server.URL) - require.NoError(t, err) - - client := NewClient(serverURL, "user", "secret") - client.HTTPClient = server.Client() - - mux.HandleFunc("/enterprise/control/agent.php", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - login := req.Header.Get("Http_auth_login") - if login != "user" { - http.Error(rw, fmt.Sprintf("invalid login: %s", login), http.StatusUnauthorized) - return - } - - password := req.Header.Get("Http_auth_passwd") - if password != "secret" { - http.Error(rw, fmt.Sprintf("invalid password: %s", password), http.StatusUnauthorized) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - return client + return client, nil + }, + servermock.CheckHeader().WithContentType("text/xml"). + With("Http_auth_login", "user"). + With("Http_auth_passwd", "secret"), + ) } func TestClient_GetSite(t *testing.T) { - client := setupTest(t, "get-site.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("get-site.xml")). + Build(t) - siteID, err := client.GetSite(context.Background(), "example.com") + siteID, err := client.GetSite(t.Context(), "example.com") require.NoError(t, err) assert.Equal(t, 82, siteID) } func TestClient_GetSite_error(t *testing.T) { - client := setupTest(t, "get-site-error.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("get-site-error.xml")). + Build(t) - siteID, err := client.GetSite(context.Background(), "example.com") + siteID, err := client.GetSite(t.Context(), "example.com") require.Error(t, err) assert.Equal(t, 0, siteID) } func TestClient_GetSite_system_error(t *testing.T) { - client := setupTest(t, "global-error.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("global-error.xml")). + Build(t) - siteID, err := client.GetSite(context.Background(), "example.com") + siteID, err := client.GetSite(t.Context(), "example.com") require.Error(t, err) assert.Equal(t, 0, siteID) } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "add-record.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("add-record.xml")). + Build(t) - recordID, err := client.AddRecord(context.Background(), 123, "_acme-challenge.example.com", "txtTXTtxt") + recordID, err := client.AddRecord(t.Context(), 123, "_acme-challenge.example.com", "txtTXTtxt") require.NoError(t, err) assert.Equal(t, 4537, recordID) } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "add-record-error.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("add-record-error.xml")). + Build(t) - recordID, err := client.AddRecord(context.Background(), 123, "_acme-challenge.example.com", "txtTXTtxt") + recordID, err := client.AddRecord(t.Context(), 123, "_acme-challenge.example.com", "txtTXTtxt") require.ErrorAs(t, err, new(RecResult)) assert.Equal(t, 0, recordID) } func TestClient_AddRecord_system_error(t *testing.T) { - client := setupTest(t, "global-error.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("global-error.xml")). + Build(t) - recordID, err := client.AddRecord(context.Background(), 123, "_acme-challenge.example.com", "txtTXTtxt") + recordID, err := client.AddRecord(t.Context(), 123, "_acme-challenge.example.com", "txtTXTtxt") require.ErrorAs(t, err, new(*System)) assert.Equal(t, 0, recordID) } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "delete-record.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("delete-record.xml")). + Build(t) - recordID, err := client.DeleteRecord(context.Background(), 4537) + recordID, err := client.DeleteRecord(t.Context(), 4537) require.NoError(t, err) assert.Equal(t, 4537, recordID) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "delete-record-error.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("delete-record-error.xml")). + Build(t) - recordID, err := client.DeleteRecord(context.Background(), 4537) + recordID, err := client.DeleteRecord(t.Context(), 4537) require.ErrorAs(t, err, new(RecResult)) assert.Equal(t, 0, recordID) } func TestClient_DeleteRecord_system_error(t *testing.T) { - client := setupTest(t, "global-error.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("global-error.xml")). + Build(t) - recordID, err := client.DeleteRecord(context.Background(), 4537) + recordID, err := client.DeleteRecord(t.Context(), 4537) require.ErrorAs(t, err, new(*System)) assert.Equal(t, 0, recordID) diff --git a/providers/dns/plesk/plesk.go b/providers/dns/plesk/plesk.go index 5b279c5f8..5f07dcb50 100644 --- a/providers/dns/plesk/plesk.go +++ b/providers/dns/plesk/plesk.go @@ -10,8 +10,10 @@ import ( "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/plesk/internal" ) @@ -29,6 +31,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { baseURL string @@ -104,6 +108,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -157,6 +163,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("plesk: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } @@ -166,5 +173,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("plesk: failed to delete record (%d): %w", recordID, err) } + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + return nil } diff --git a/providers/dns/plesk/plesk.toml b/providers/dns/plesk/plesk.toml index 3a67065d6..0ef89d6b7 100644 --- a/providers/dns/plesk/plesk.toml +++ b/providers/dns/plesk/plesk.toml @@ -8,7 +8,7 @@ Example = ''' PLESK_SERVER_BASE_URL="https://plesk.myserver.com:8443" \ PLESK_USERNAME=xxxxxx \ PLESK_PASSWORD=yyyyyy \ -lego --email you@example.com --dns plesk -d '*.example.com' -d example.com run +lego --dns plesk -d '*.example.com' -d example.com run ''' [Configuration] @@ -17,10 +17,10 @@ lego --email you@example.com --dns plesk -d '*.example.com' -d example.com run PLESK_USERNAME = "API username" PLESK_PASSWORD = "API password" [Configuration.Additional] - PLESK_POLLING_INTERVAL = "Time between DNS propagation check" - PLESK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - PLESK_TTL = "The TTL of the TXT record used for the DNS challenge" - PLESK_HTTP_TIMEOUT = "API request timeout" + PLESK_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + PLESK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + PLESK_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + PLESK_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference.28784/" diff --git a/providers/dns/plesk/plesk_test.go b/providers/dns/plesk/plesk_test.go index 417e2c1da..506a26a2a 100644 --- a/providers/dns/plesk/plesk_test.go +++ b/providers/dns/plesk/plesk_test.go @@ -67,6 +67,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -149,6 +150,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -162,6 +164,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/porkbun/porkbun.go b/providers/dns/porkbun/porkbun.go index 3df5120fb..2f999ebcc 100644 --- a/providers/dns/porkbun/porkbun.go +++ b/providers/dns/porkbun/porkbun.go @@ -10,8 +10,10 @@ import ( "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/nrdcg/porkbun" ) @@ -30,6 +32,8 @@ const ( const minTTL = 300 +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -97,6 +101,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -148,6 +154,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("porkbun: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } @@ -164,6 +171,10 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("porkbun: failed to delete record: %w", err) } + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + return nil } diff --git a/providers/dns/porkbun/porkbun.toml b/providers/dns/porkbun/porkbun.toml index 91b0b1329..9ae036da6 100644 --- a/providers/dns/porkbun/porkbun.toml +++ b/providers/dns/porkbun/porkbun.toml @@ -1,5 +1,6 @@ Name = "Porkbun" Description = '''''' +# This URL is NOT the API URL. URL = "https://porkbun.com/" Code = "porkbun" Since = "v4.4.0" @@ -7,7 +8,7 @@ Since = "v4.4.0" Example = ''' PORKBUN_SECRET_API_KEY=xxxxxx \ PORKBUN_API_KEY=yyyyyy \ -lego --email you@example.com --dns porkbun -d '*.example.com' -d example.com run +lego --dns porkbun -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,10 +16,10 @@ lego --email you@example.com --dns porkbun -d '*.example.com' -d example.com run PORKBUN_SECRET_API_KEY = "secret API key" PORKBUN_API_KEY = "API key" [Configuration.Additional] - PORKBUN_POLLING_INTERVAL = "Time between DNS propagation check" - PORKBUN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - PORKBUN_TTL = "The TTL of the TXT record used for the DNS challenge" - PORKBUN_HTTP_TIMEOUT = "API request timeout" + PORKBUN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + PORKBUN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)" + PORKBUN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + PORKBUN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://porkbun.com/api/json/v3/documentation" diff --git a/providers/dns/porkbun/porkbun_test.go b/providers/dns/porkbun/porkbun_test.go index cdf022b5d..7c69edfdb 100644 --- a/providers/dns/porkbun/porkbun_test.go +++ b/providers/dns/porkbun/porkbun_test.go @@ -54,6 +54,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -124,6 +125,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -137,6 +139,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/rackspace/fixtures/delete.json b/providers/dns/rackspace/fixtures/delete.json new file mode 100644 index 000000000..7e2f2ac53 --- /dev/null +++ b/providers/dns/rackspace/fixtures/delete.json @@ -0,0 +1,7 @@ +{ + "status": "RUNNING", + "verb": "DELETE", + "jobId": "00000000-0000-0000-0000-0000000000", + "callbackUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000", + "requestUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/recordsid=TXT-654321" +} diff --git a/providers/dns/rackspace/fixtures/identity.json b/providers/dns/rackspace/fixtures/identity.json new file mode 100644 index 000000000..5a459d13c --- /dev/null +++ b/providers/dns/rackspace/fixtures/identity.json @@ -0,0 +1,31 @@ +{ + "access": { + "token": { + "id": "testToken", + "expires": "1970-01-01T00:00:00.000Z", + "tenant": { + "id": "123456", + "name": "123456" + }, + "RAX-AUTH:authenticatedBy": [ + "APIKEY" + ] + }, + "serviceCatalog": [ + { + "type": "rax:dns", + "endpoints": [ + { + "publicURL": "https://dns.api.rackspacecloud.com/v1.0/123456", + "tenantId": "123456" + } + ], + "name": "cloudDNS" + } + ], + "user": { + "id": "fakeUseID", + "name": "testUser" + } + } +} diff --git a/providers/dns/rackspace/fixtures/record.json b/providers/dns/rackspace/fixtures/record.json new file mode 100644 index 000000000..4d76aa0c8 --- /dev/null +++ b/providers/dns/rackspace/fixtures/record.json @@ -0,0 +1,8 @@ +{ + "request": "{\"records\":[{\"name\":\"_acme-challenge.example.com\",\"type\":\"TXT\",\"data\":\"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM\",\"ttl\":300}]}", + "status": "RUNNING", + "verb": "POST", + "jobId": "00000000-0000-0000-0000-0000000000", + "callbackUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000", + "requestUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/records" +} diff --git a/providers/dns/rackspace/fixtures/record_details.json b/providers/dns/rackspace/fixtures/record_details.json new file mode 100644 index 000000000..e53cf1330 --- /dev/null +++ b/providers/dns/rackspace/fixtures/record_details.json @@ -0,0 +1,13 @@ +{ + "records": [ + { + "name": "_acme-challenge.example.com", + "id": "TXT-654321", + "type": "TXT", + "data": "pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM", + "ttl": 300, + "updated": "1970-01-01T00:00:00.000+0000", + "created": "1970-01-01T00:00:00.000+0000" + } + ] +} diff --git a/providers/dns/rackspace/fixtures/zone_details.json b/providers/dns/rackspace/fixtures/zone_details.json new file mode 100644 index 000000000..f68f23aa0 --- /dev/null +++ b/providers/dns/rackspace/fixtures/zone_details.json @@ -0,0 +1,12 @@ +{ + "domains": [ + { + "name": "example.com", + "id": "112233", + "emailAddress": "hostmaster@example.com", + "updated": "1970-01-01T00:00:00.000+0000", + "created": "1970-01-01T00:00:00.000+0000" + } + ], + "totalEntries": 1 +} diff --git a/providers/dns/rackspace/internal/client.go b/providers/dns/rackspace/internal/client.go index cbfdd1bfa..4a1872484 100644 --- a/providers/dns/rackspace/internal/client.go +++ b/providers/dns/rackspace/internal/client.go @@ -14,6 +14,8 @@ import ( "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) +const AuthToken = "X-Auth-Token" + type Client struct { token string @@ -21,7 +23,7 @@ type Client struct { HTTPClient *http.Client } -func NewClient(endpoint string, token string) (*Client, error) { +func NewClient(endpoint, token string) (*Client, error) { baseURL, err := url.Parse(endpoint) if err != nil { return nil, err @@ -34,7 +36,7 @@ func NewClient(endpoint string, token string) (*Client, error) { }, nil } -// AddRecord Adds one record to a specified domain. +// AddRecord Adds one record to a specified domain. // https://docs.rackspace.com/docs/cloud-dns/v1/api-reference/records#add-records func (c *Client) AddRecord(ctx context.Context, zoneID string, record Record) error { endpoint := c.baseURL.JoinPath("domains", zoneID, "records") @@ -111,6 +113,7 @@ func (c *Client) listDomainsByName(ctx context.Context, domain string) (*ZoneSea } var zoneSearchResponse ZoneSearchResponse + err = c.do(req, &zoneSearchResponse) if err != nil { return nil, err @@ -120,7 +123,7 @@ func (c *Client) listDomainsByName(ctx context.Context, domain string) (*ZoneSea } // FindTxtRecord searches a DNS zone for a TXT record with a specific name. -func (c *Client) FindTxtRecord(ctx context.Context, fqdn string, zoneID string) (*Record, error) { +func (c *Client) FindTxtRecord(ctx context.Context, fqdn, zoneID string) (*Record, error) { records, err := c.searchRecords(ctx, zoneID, dns01.UnFqdn(fqdn), "TXT") if err != nil { return nil, err @@ -152,6 +155,7 @@ func (c *Client) searchRecords(ctx context.Context, zoneID, recordName, recordTy } var records Records + err = c.do(req, &records) if err != nil { return nil, err @@ -161,7 +165,7 @@ func (c *Client) searchRecords(ctx context.Context, zoneID, recordName, recordTy } func (c *Client) do(req *http.Request, result any) error { - req.Header.Set("X-Auth-Token", c.token) + req.Header.Set(AuthToken, c.token) resp, err := c.HTTPClient.Do(req) if err != nil { @@ -191,7 +195,7 @@ func (c *Client) do(req *http.Request, result any) error { return nil } -func newJSONRequest[T string | *url.URL](ctx context.Context, method string, endpoint T, payload interface{}) (*http.Request, error) { +func newJSONRequest[T string | *url.URL](ctx context.Context, method string, endpoint T, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { diff --git a/providers/dns/rackspace/internal/client_test.go b/providers/dns/rackspace/internal/client_test.go index 993d34d9f..c14c4d360 100644 --- a/providers/dns/rackspace/internal/client_test.go +++ b/providers/dns/rackspace/internal/client_test.go @@ -1,81 +1,64 @@ package internal import ( - "context" - "fmt" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() - client, err := NewClient(server.URL, "secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - - mux.HandleFunc(pattern, handler) - - return client -} - -func writeFixtureHandler(method, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - if req.Header.Get("X-Auth-Token") != "secret" { - http.Error(rw, fmt.Sprintf("invalid token: %q", req.Header.Get("X-Auth-Token")), http.StatusUnauthorized) - return - } - - if filename == "" { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) - } + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With(AuthToken, "secret")) } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "/domains/1234/records", writeFixtureHandler(http.MethodPost, "add-records.json")) + client := mockBuilder(). + Route("POST /domains/1234/records", + servermock.ResponseFromFixture("add-records.json"), + servermock.CheckRequestJSONBody(`{"records":[{"name":"exmaple.com","type":"TXT","data":"value1","ttl":120,"id":"abc"}]}`)). + Build(t) - err := client.AddRecord(context.Background(), "1234", Record{}) + record := Record{ + Name: "exmaple.com", + Type: "TXT", + Data: "value1", + TTL: 120, + ID: "abc", + } + + err := client.AddRecord(t.Context(), "1234", record) require.NoError(t, err) } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "/domains/1234/records", writeFixtureHandler(http.MethodDelete, "")) + client := mockBuilder(). + Route("DELETE /domains/1234/records", nil). + Build(t) - err := client.DeleteRecord(context.Background(), "1234", "2725233") + err := client.DeleteRecord(t.Context(), "1234", "2725233") require.NoError(t, err) } func TestClient_searchRecords(t *testing.T) { - client := setupTest(t, "/domains/1234/records", writeFixtureHandler(http.MethodGet, "search-records.json")) + client := mockBuilder(). + Route("GET /domains/1234/records", servermock.ResponseFromFixture("search-records.json")). + Build(t) - records, err := client.searchRecords(context.Background(), "1234", "2725233", "A") + records, err := client.searchRecords(t.Context(), "1234", "2725233", "A") require.NoError(t, err) expected := &Records{ @@ -94,9 +77,11 @@ func TestClient_searchRecords(t *testing.T) { } func TestClient_listDomainsByName(t *testing.T) { - client := setupTest(t, "/domains", writeFixtureHandler(http.MethodGet, "list-domains-by-name.json")) + client := mockBuilder(). + Route("GET /domains", servermock.ResponseFromFixture("list-domains-by-name.json")). + Build(t) - domains, err := client.listDomainsByName(context.Background(), "1234") + domains, err := client.listDomainsByName(t.Context(), "1234") require.NoError(t, err) expected := &ZoneSearchResponse{ diff --git a/providers/dns/rackspace/internal/identity.go b/providers/dns/rackspace/internal/identity.go index 062350df5..3ff667fb8 100644 --- a/providers/dns/rackspace/internal/identity.go +++ b/providers/dns/rackspace/internal/identity.go @@ -65,6 +65,7 @@ func (a *Identifier) Login(ctx context.Context, apiUser, apiKey string) (*Identi } var identity Identity + err = json.Unmarshal(raw, &identity) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) diff --git a/providers/dns/rackspace/internal/identity_test.go b/providers/dns/rackspace/internal/identity_test.go index 9ba5abb50..44a8d75fc 100644 --- a/providers/dns/rackspace/internal/identity_test.go +++ b/providers/dns/rackspace/internal/identity_test.go @@ -1,51 +1,24 @@ package internal import ( - "context" - "fmt" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func writeIdentityFixtureHandler(method, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - if filename == "" { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) - } +func setupIdentifier(server *httptest.Server) (*Identifier, error) { + return NewIdentifier(server.Client(), server.URL), nil } func TestIdentifier_Login(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + identifier := servermock.NewBuilder[*Identifier](setupIdentifier, servermock.CheckHeader().WithJSONHeaders()). + Route("POST /", servermock.ResponseFromFixture("tokens.json")). + Build(t) - identifier := NewIdentifier(server.Client(), server.URL) - - mux.HandleFunc("/", writeIdentityFixtureHandler(http.MethodPost, "tokens.json")) - - identity, err := identifier.Login(context.Background(), "user", "secret") + identity, err := identifier.Login(t.Context(), "user", "secret") require.NoError(t, err) expected := &Identity{ diff --git a/providers/dns/rackspace/rackspace.go b/providers/dns/rackspace/rackspace.go index c877de3b8..b4c7b4a0f 100644 --- a/providers/dns/rackspace/rackspace.go +++ b/providers/dns/rackspace/rackspace.go @@ -8,8 +8,10 @@ import ( "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/rackspace/internal" ) @@ -26,6 +28,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string @@ -95,6 +99,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { // Iterate through the Service Catalog to get the DNS Endpoint var dnsEndpoint string + for _, service := range identity.Access.ServiceCatalog { if service.Name == "cloudDNS" { dnsEndpoint = service.Endpoints[0].PublicURL @@ -115,6 +120,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, diff --git a/providers/dns/rackspace/rackspace.toml b/providers/dns/rackspace/rackspace.toml index ae0b0fca4..0a4a80ffc 100644 --- a/providers/dns/rackspace/rackspace.toml +++ b/providers/dns/rackspace/rackspace.toml @@ -7,7 +7,7 @@ Since = "v0.4.0" Example = ''' RACKSPACE_USER=xxxx \ RACKSPACE_API_KEY=yyyy \ -lego --email you@example.com --dns rackspace -d '*.example.com' -d example.com run +lego --dns rackspace -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,10 +15,10 @@ lego --email you@example.com --dns rackspace -d '*.example.com' -d example.com r RACKSPACE_USER = "API user" RACKSPACE_API_KEY = "API key" [Configuration.Additional] - RACKSPACE_POLLING_INTERVAL = "Time between DNS propagation check" - RACKSPACE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - RACKSPACE_TTL = "The TTL of the TXT record used for the DNS challenge" - RACKSPACE_HTTP_TIMEOUT = "API request timeout" + RACKSPACE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 3)" + RACKSPACE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + RACKSPACE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + RACKSPACE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developer.rackspace.com/docs/cloud-dns/v1/" diff --git a/providers/dns/rackspace/rackspace_mock_test.go b/providers/dns/rackspace/rackspace_mock_test.go deleted file mode 100644 index 790d52498..000000000 --- a/providers/dns/rackspace/rackspace_mock_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package rackspace - -const recordDeleteMock = ` -{ - "status": "RUNNING", - "verb": "DELETE", - "jobId": "00000000-0000-0000-0000-0000000000", - "callbackUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000", - "requestUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/recordsid=TXT-654321" -} -` - -const recordDetailsMock = ` -{ - "records": [ - { - "name": "_acme-challenge.example.com", - "id": "TXT-654321", - "type": "TXT", - "data": "pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM", - "ttl": 300, - "updated": "1970-01-01T00:00:00.000+0000", - "created": "1970-01-01T00:00:00.000+0000" - } - ] -} -` - -const zoneDetailsMock = ` -{ - "domains": [ - { - "name": "example.com", - "id": "112233", - "emailAddress": "hostmaster@example.com", - "updated": "1970-01-01T00:00:00.000+0000", - "created": "1970-01-01T00:00:00.000+0000" - } - ], - "totalEntries": 1 -} -` - -const identityResponseMock = ` -{ - "access": { - "token": { - "id": "testToken", - "expires": "1970-01-01T00:00:00.000Z", - "tenant": { - "id": "123456", - "name": "123456" - }, - "RAX-AUTH:authenticatedBy": [ - "APIKEY" - ] - }, - "serviceCatalog": [ - { - "type": "rax:dns", - "endpoints": [ - { - "publicURL": "https://dns.api.rackspacecloud.com/v1.0/123456", - "tenantId": "123456" - } - ], - "name": "cloudDNS" - } - ], - "user": { - "id": "fakeUseID", - "name": "testUser" - } - } -} -` - -const recordResponseMock = ` -{ - "request": "{\"records\":[{\"name\":\"_acme-challenge.example.com\",\"type\":\"TXT\",\"data\":\"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM\",\"ttl\":300}]}", - "status": "RUNNING", - "verb": "POST", - "jobId": "00000000-0000-0000-0000-0000000000", - "callbackUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000", - "requestUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/records" -} -` diff --git a/providers/dns/rackspace/rackspace_test.go b/providers/dns/rackspace/rackspace_test.go index cbc57b472..de0749fd3 100644 --- a/providers/dns/rackspace/rackspace_test.go +++ b/providers/dns/rackspace/rackspace_test.go @@ -1,9 +1,7 @@ package rackspace import ( - "bytes" "fmt" - "io" "net/http" "net/http/httptest" "strings" @@ -11,6 +9,7 @@ import ( "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -23,11 +22,7 @@ var envTest = tester.NewEnvTest( WithDomain(envDomain) func TestNewDNSProviderConfig(t *testing.T) { - config := setupTest(t) - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - assert.NotNil(t, provider.config) + provider := mockBuilder().Build(t) assert.Equal(t, "testToken", provider.token, "The token should match") } @@ -38,25 +33,40 @@ func TestNewDNSProviderConfig_MissingCredErr(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - config := setupTest(t) + provider := mockBuilder(). + Route("GET /123456/domains", + servermock.ResponseFromFixture("zone_details.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com")). + Route("POST /123456/domains/112233/records", + servermock.ResponseFromFixture("record.json"). + WithStatusCode(http.StatusAccepted), + servermock.CheckRequestJSONBody(`{"records":[{"name":"_acme-challenge.example.com","type":"TXT","data":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM","ttl":300}]}`)). + Build(t) - provider, err := NewDNSProviderConfig(config) - - if assert.NoError(t, err) { - err = provider.Present("example.com", "token", "keyAuth") - require.NoError(t, err) - } + err := provider.Present("example.com", "token", "keyAuth") + require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { - config := setupTest(t) + provider := mockBuilder(). + Route("GET /123456/domains", + servermock.ResponseFromFixture("zone_details.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com")). + Route("GET /123456/domains/112233/records", + servermock.ResponseFromFixture("record_details.json"), + servermock.CheckQueryParameter().Strict(). + With("type", "TXT"). + With("name", "_acme-challenge.example.com")). + Route("DELETE /123456/domains/112233/records", + servermock.ResponseFromFixture("delete.json"), + servermock.CheckQueryParameter().Strict(). + With("id", "TXT-654321")). + Build(t) - provider, err := NewDNSProviderConfig(config) - - if assert.NoError(t, err) { - err = provider.CleanUp("example.com", "token", "keyAuth") - require.NoError(t, err) - } + err := provider.CleanUp("example.com", "token", "keyAuth") + require.NoError(t, err) } func TestLiveNewDNSProvider_ValidEnv(t *testing.T) { @@ -65,6 +75,7 @@ func TestLiveNewDNSProvider_ValidEnv(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -77,6 +88,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -90,6 +102,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -99,99 +112,60 @@ func TestLiveCleanUp(t *testing.T) { require.NoError(t, err) } -func setupTest(t *testing.T) *Config { - t.Helper() +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.HTTPClient = server.Client() + config.APIUser = "testUser" + config.APIKey = "testKey" + config.HTTPClient = server.Client() + config.BaseURL = server.URL + "/v2.0/tokens" - dnsAPI := httptest.NewServer(dnsHandler()) - t.Cleanup(dnsAPI.Close) + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader().WithJSONHeaders(), + ). + Route("POST /v2.0/tokens", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + apiURL := fmt.Sprintf("http://%s/123456", req.Context().Value(http.LocalAddrContextKey)) - identityAPI := httptest.NewServer(identityHandler(dnsAPI.URL + "/123456")) - t.Cleanup(identityAPI.Close) - - config := NewDefaultConfig() - config.APIUser = "testUser" - config.APIKey = "testKey" - config.HTTPClient = identityAPI.Client() - config.BaseURL = identityAPI.URL + "/" - - return config + resp := strings.Replace(` +{ + "access": { + "token": { + "id": "testToken", + "expires": "1970-01-01T00:00:00.000Z", + "tenant": { + "id": "123456", + "name": "123456" + }, + "RAX-AUTH:authenticatedBy": [ + "APIKEY" + ] + }, + "serviceCatalog": [ + { + "type": "rax:dns", + "endpoints": [ + { + "publicURL": "https://dns.api.rackspacecloud.com/v1.0/123456", + "tenantId": "123456" + } + ], + "name": "cloudDNS" + } + ], + "user": { + "id": "fakeUseID", + "name": "testUser" + } + } } +`, "https://dns.api.rackspacecloud.com/v1.0/123456", apiURL, 1) -func identityHandler(dnsEndpoint string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - reqBody, err := io.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - if string(bytes.TrimSpace(reqBody)) != `{"auth":{"RAX-KSKEY:apiKeyCredentials":{"username":"testUser","apiKey":"testKey"}}}` { - http.Error(w, fmt.Sprintf("invalid body: %s", string(reqBody)), http.StatusBadRequest) - return - } - - resp := strings.Replace(identityResponseMock, "https://dns.api.rackspacecloud.com/v1.0/123456", dnsEndpoint, 1) - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, resp) - }) -} - -func dnsHandler() *http.ServeMux { - mux := http.NewServeMux() - - // Used by `getHostedZoneID()` finding `zoneID` "?name=example.com" - mux.HandleFunc("/123456/domains", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("name") == "example.com" { - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, zoneDetailsMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/123456/domains/112233/records", func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - // Used by `Present()` creating the TXT record - case http.MethodPost: - reqBody, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - if string(bytes.TrimSpace(reqBody)) != `{"records":[{"name":"_acme-challenge.example.com","type":"TXT","data":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM","ttl":300}]}` { - http.Error(w, fmt.Sprintf("invalid body: %s", string(reqBody)), http.StatusBadRequest) - return - } - - w.WriteHeader(http.StatusAccepted) - _, _ = fmt.Fprint(w, recordResponseMock) - - // Used by `findTxtRecord()` finding `record.ID` "?type=TXT&name=_acme-challenge.example.com" - case http.MethodGet: - if r.URL.Query().Get("type") == "TXT" && r.URL.Query().Get("name") == "_acme-challenge.example.com" { - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, recordDetailsMock) - return - } - - w.WriteHeader(http.StatusBadRequest) - return - - // Used by `CleanUp()` deleting the TXT record "?id=445566" - case http.MethodDelete: - if r.URL.Query().Get("id") == "TXT-654321" { - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, recordDeleteMock) - return - } - w.WriteHeader(http.StatusBadRequest) - } - }) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, fmt.Sprintf("Not Found for Request: (%+v)", r), http.StatusNotFound) - }) - - return mux + rw.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(rw, resp) + }), + servermock.CheckRequestJSONBody(`{"auth":{"RAX-KSKEY:apiKeyCredentials":{"username":"testUser","apiKey":"testKey"}}}`)) } diff --git a/providers/dns/rainyun/internal/client.go b/providers/dns/rainyun/internal/client.go new file mode 100644 index 000000000..595b39f29 --- /dev/null +++ b/providers/dns/rainyun/internal/client.go @@ -0,0 +1,184 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + querystring "github.com/google/go-querystring/query" +) + +const defaultBaseURL = "https://api.v2.rainyun.com/product/" + +// Client the Rain Yun API client. +type Client struct { + apiKey string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(apiKey string) (*Client, error) { + if apiKey == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + apiKey: apiKey, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) AddRecord(ctx context.Context, domainID int, record Record) error { + endpoint := c.baseURL.JoinPath("domain", strconv.Itoa(domainID), "dns") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int) error { + endpoint := c.baseURL.JoinPath("domain", strconv.Itoa(domainID), "dns") + + values, err := querystring.Values(Record{ID: recordID}) + if err != nil { + return err + } + + endpoint.RawQuery = values.Encode() + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) ListRecords(ctx context.Context, domainID int) ([]Record, error) { + endpoint := c.baseURL.JoinPath("domain", strconv.Itoa(domainID), "dns") + + query := endpoint.Query() + query.Set("limit", "100") + query.Set("page_no", "1") + endpoint.RawQuery = query.Encode() + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var recordData APIResponse[Record] + + err = c.do(req, &recordData) + if err != nil { + return nil, err + } + + return recordData.Data.Records, nil +} + +func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { + endpoint := c.baseURL.JoinPath("domain") + + query := endpoint.Query() + query.Set("options", `{"columnFilters":{"domains.Domain":""},"sort":[],"page":1,"perPage":100}`) + endpoint.RawQuery = query.Encode() + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var domainData APIResponse[Domain] + + err = c.do(req, &domainData) + if err != nil { + return nil, err + } + + return domainData.Data.Records, nil +} + +func (c *Client) do(req *http.Request, result any) error { + req.Header.Add("x-api-key", c.apiKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/rainyun/internal/client_test.go b/providers/dns/rainyun/internal/client_test.go new file mode 100644 index 000000000..8246001af --- /dev/null +++ b/providers/dns/rainyun/internal/client_test.go @@ -0,0 +1,167 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders()) +} + +func TestClient_ListDomains(t *testing.T) { + client := mockBuilder(). + Route("GET /domain", + servermock.ResponseFromFixture("domains.json"), + servermock.CheckQueryParameter().Strict(). + With("options", `{"columnFilters":{"domains.Domain":""},"sort":[],"page":1,"perPage":100}`)). + Build(t) + + domains, err := client.ListDomains(t.Context()) + require.NoError(t, err) + + expected := []Domain{ + {ID: 1, Domain: "example.com"}, + {ID: 2, Domain: "example.org"}, + } + + assert.Equal(t, expected, domains) +} + +func TestClient_ListDomains_error(t *testing.T) { + client := mockBuilder(). + Route("GET /domain", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusForbidden)). + Build(t) + + _, err := client.ListDomains(t.Context()) + require.Error(t, err) + + assert.EqualError(t, err, "30039: 密钥认证错误或已失效") +} + +func TestClient_ListRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /domain/123/dns", + servermock.ResponseFromFixture("records.json"), + servermock.CheckQueryParameter().Strict(). + With("limit", "100"). + With("page_no", "1")). + Build(t) + + records, err := client.ListRecords(t.Context(), 123) + require.NoError(t, err) + + expected := []Record{ + { + ID: 1, + Host: "_acme-challenge.foo.example.com", + Line: "DEFAULT", + TTL: 120, + Type: "TXT", + Value: "foo", + }, + { + ID: 2, + Host: "_acme-challenge.bar.example.com", + Line: "DEFAULT", + TTL: 300, + Type: "TXT", + Value: "bar", + }, + } + + assert.Equal(t, expected, records) +} + +func TestClient_ListRecords_error(t *testing.T) { + client := mockBuilder(). + Route("GET /domain/123/dns", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusForbidden)). + Build(t) + + _, err := client.ListRecords(t.Context(), 123) + require.Error(t, err) + + assert.EqualError(t, err, "30039: 密钥认证错误或已失效") +} + +func TestClient_AddRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /domain/123/dns", nil). + Build(t) + + record := Record{ + Host: "_acme-challenge.foo.example.com", + Line: "DEFAULT", + TTL: 120, + Type: "TXT", + Value: "foo", + } + + err := client.AddRecord(t.Context(), 123, record) + require.NoError(t, err) +} + +func TestClient_AddRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /domain/123/dns", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusForbidden)). + Build(t) + + record := Record{ + Host: "_acme-challenge.foo.example.com", + Line: "DEFAULT", + TTL: 120, + Type: "TXT", + Value: "foo", + } + + err := client.AddRecord(t.Context(), 123, record) + require.Error(t, err) + + assert.EqualError(t, err, "30039: 密钥认证错误或已失效") +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domain/123/dns", nil). + Build(t) + + err := client.DeleteRecord(t.Context(), 123, 456) + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domain/123/dns", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusForbidden)). + Build(t) + + err := client.DeleteRecord(t.Context(), 123, 456) + require.Error(t, err) + + assert.EqualError(t, err, "30039: 密钥认证错误或已失效") +} diff --git a/providers/dns/rainyun/internal/fixtures/domains.json b/providers/dns/rainyun/internal/fixtures/domains.json new file mode 100644 index 000000000..930e4e189 --- /dev/null +++ b/providers/dns/rainyun/internal/fixtures/domains.json @@ -0,0 +1,16 @@ +{ + "code": 0, + "data": { + "TotalRecords": 2, + "Records": [ + { + "id": 1, + "domain": "example.com" + }, + { + "id": 2, + "domain": "example.org" + } + ] + } +} diff --git a/providers/dns/rainyun/internal/fixtures/error.json b/providers/dns/rainyun/internal/fixtures/error.json new file mode 100644 index 000000000..31e9f7138 --- /dev/null +++ b/providers/dns/rainyun/internal/fixtures/error.json @@ -0,0 +1,4 @@ +{ + "code": 30039, + "message": "密钥认证错误或已失效" +} diff --git a/providers/dns/rainyun/internal/fixtures/records.json b/providers/dns/rainyun/internal/fixtures/records.json new file mode 100644 index 000000000..d24c0c9ec --- /dev/null +++ b/providers/dns/rainyun/internal/fixtures/records.json @@ -0,0 +1,24 @@ +{ + "code": 0, + "data": { + "TotalRecords": 2, + "Records": [ + { + "record_id": 1, + "host": "_acme-challenge.foo.example.com", + "type": "TXT", + "TTL": 120, + "value": "foo", + "line": "DEFAULT" + }, + { + "record_id": 2, + "host": "_acme-challenge.bar.example.com", + "type": "TXT", + "TTL": 300, + "value": "bar", + "line": "DEFAULT" + } + ] + } +} diff --git a/providers/dns/rainyun/internal/types.go b/providers/dns/rainyun/internal/types.go new file mode 100644 index 000000000..8ce559112 --- /dev/null +++ b/providers/dns/rainyun/internal/types.go @@ -0,0 +1,37 @@ +package internal + +import "fmt" + +type APIError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (a *APIError) Error() string { + return fmt.Sprintf("%d: %s", a.Code, a.Message) +} + +type Record struct { + ID int `json:"record_id,omitempty" url:"record_id,omitempty"` + Host string `json:"host,omitempty" url:"host,omitempty"` + Priority int `json:"level,omitempty" url:"level,omitempty"` + Line string `json:"line,omitempty" url:"line,omitempty"` + TTL int `json:"ttl,omitempty" url:"ttl,omitempty"` + Type string `json:"type,omitempty" url:"type,omitempty"` + Value string `json:"value,omitempty" url:"value,omitempty"` +} + +type Domain struct { + ID int `json:"id,omitempty"` + Domain string `json:"domain,omitempty"` +} + +type APIResponse[T any] struct { + Code int `json:"code"` + Data *Data[T] `json:"data"` +} + +type Data[T any] struct { + TotalRecords int `json:"TotalRecords"` + Records []T `json:"Records"` +} diff --git a/providers/dns/rainyun/rainyun.go b/providers/dns/rainyun/rainyun.go new file mode 100644 index 000000000..a4d1c4035 --- /dev/null +++ b/providers/dns/rainyun/rainyun.go @@ -0,0 +1,200 @@ +// Package rainyun implements a DNS provider for solving the DNS-01 challenge using Rain Yun. +package rainyun + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/rainyun/internal" +) + +// Environment variables names. +const ( + envNamespace = "RAINYUN_" + + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIKey string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Rain Yun. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("rainyun: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Rain Yun. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("rainyun: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.APIKey) + if err != nil { + return nil, fmt.Errorf("rainyun: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx := context.Background() + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("rainyun: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("rainyun: %w", err) + } + + domainID, err := d.findDomainID(ctx, dns01.UnFqdn(authZone)) + if err != nil { + return fmt.Errorf("rainyun: find domain ID: %w", err) + } + + record := internal.Record{ + Host: subDomain, + Priority: 10, + Line: "DEFAULT", + TTL: d.config.TTL, + Type: "TXT", + Value: info.Value, + } + + err = d.client.AddRecord(ctx, domainID, record) + if err != nil { + return fmt.Errorf("rainyun: add record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx := context.Background() + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("rainyun: could not find zone for domain %q: %w", domain, err) + } + + domainID, err := d.findDomainID(ctx, dns01.UnFqdn(authZone)) + if err != nil { + return fmt.Errorf("rainyun: find domain ID: %w", err) + } + + recordID, err := d.findRecordID(ctx, domainID, info) + if err != nil { + return fmt.Errorf("rainyun: find record ID: %w", err) + } + + err = d.client.DeleteRecord(ctx, domainID, recordID) + if err != nil { + return fmt.Errorf("rainyun: delete record: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) findDomainID(ctx context.Context, domain string) (int, error) { + domains, err := d.client.ListDomains(ctx) + if err != nil { + return 0, err + } + + for _, dom := range domains { + if dom.Domain == domain { + return dom.ID, nil + } + } + + return 0, fmt.Errorf("domain not found: %s", domain) +} + +func (d *DNSProvider) findRecordID(ctx context.Context, domainID int, info dns01.ChallengeInfo) (int, error) { + records, err := d.client.ListRecords(ctx, domainID) + if err != nil { + return 0, fmt.Errorf("list records: %w", err) + } + + zone := dns01.UnFqdn(info.EffectiveFQDN) + + for _, record := range records { + if strings.HasPrefix(zone, record.Host) && record.Value == info.Value { + return record.ID, nil + } + } + + return 0, fmt.Errorf("record not found: domainID=%d, fqdn=%s", domainID, info.EffectiveFQDN) +} diff --git a/providers/dns/rainyun/rainyun.toml b/providers/dns/rainyun/rainyun.toml new file mode 100644 index 000000000..fe2b3c07d --- /dev/null +++ b/providers/dns/rainyun/rainyun.toml @@ -0,0 +1,22 @@ +Name = "Rain Yun/雨云" +Description = '''''' +URL = "https://www.rainyun.com" +Code = "rainyun" +Since = "v4.21.0" + +Example = ''' +RAINYUN_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns rainyun -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + RAINYUN_API_KEY = "API key" + [Configuration.Additional] + RAINYUN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + RAINYUN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + RAINYUN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + RAINYUN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://www.apifox.cn/apidoc/shared-a4595cc8-44c5-4678-a2a3-eed7738dab03/api-151416609" diff --git a/providers/dns/rainyun/rainyun_test.go b/providers/dns/rainyun/rainyun_test.go new file mode 100644 index 000000000..d27d47e81 --- /dev/null +++ b/providers/dns/rainyun/rainyun_test.go @@ -0,0 +1,116 @@ +package rainyun + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "secret", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "rainyun: some credentials information are missing: RAINYUN_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + expected string + }{ + { + desc: "success", + apiKey: "secret", + }, + { + desc: "missing credentials", + expected: "rainyun: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/rcodezero/internal/client.go b/providers/dns/rcodezero/internal/client.go index d37fec2dd..5cf39907e 100644 --- a/providers/dns/rcodezero/internal/client.go +++ b/providers/dns/rcodezero/internal/client.go @@ -64,6 +64,7 @@ func (c *Client) do(req *http.Request) (*APIResponse, error) { } result := &APIResponse{} + raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) @@ -105,6 +106,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errAPI := &APIResponse{} + err := json.Unmarshal(raw, errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/rcodezero/internal/client_test.go b/providers/dns/rcodezero/internal/client_test.go index c19e6e5b8..b70107072 100644 --- a/providers/dns/rcodezero/internal/client_test.go +++ b/providers/dns/rcodezero/internal/client_test.go @@ -1,69 +1,30 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - apiToken := req.Header.Get(authorizationHeader) - if apiToken != "Bearer secret" { - http.Error(rw, fmt.Sprintf("invalid credentials: %s", apiToken), http.StatusBadRequest) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) - return client + return client, nil } func TestClient_UpdateRecords_error(t *testing.T) { - client := setupTest(t, http.MethodPatch, "/v1/acme/zones/example.org/rrsets", http.StatusUnprocessableEntity, "error.json") + client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders()). + Route("PATCH /v1/acme/zones/example.org/rrsets", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnprocessableEntity)). + Build(t) rrSet := []UpdateRRSet{{ Name: "acme.example.org.", @@ -72,13 +33,16 @@ func TestClient_UpdateRecords_error(t *testing.T) { Records: []Record{{Content: `"my-acme-challenge"`}}, }} - resp, err := client.UpdateRecords(context.Background(), "example.org", rrSet) + resp, err := client.UpdateRecords(t.Context(), "example.org", rrSet) require.ErrorAs(t, err, new(*APIResponse)) assert.Nil(t, resp) } func TestClient_UpdateRecords(t *testing.T) { - client := setupTest(t, http.MethodPatch, "/v1/acme/zones/example.org/rrsets", http.StatusOK, "rrsets-response.json") + client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders()). + Route("PATCH /v1/acme/zones/example.org/rrsets", + servermock.ResponseFromFixture("rrsets-response.json")). + Build(t) rrSet := []UpdateRRSet{{ Name: "acme.example.org.", @@ -87,7 +51,7 @@ func TestClient_UpdateRecords(t *testing.T) { Records: []Record{{Content: `"my-acme-challenge"`}}, }} - resp, err := client.UpdateRecords(context.Background(), "example.org", rrSet) + resp, err := client.UpdateRecords(t.Context(), "example.org", rrSet) require.NoError(t, err) expected := &APIResponse{Status: "ok", Message: "RRsets updated"} diff --git a/providers/dns/rcodezero/rcodezero.go b/providers/dns/rcodezero/rcodezero.go index 3011f193f..010a6dadc 100644 --- a/providers/dns/rcodezero/rcodezero.go +++ b/providers/dns/rcodezero/rcodezero.go @@ -8,8 +8,10 @@ import ( "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/rcodezero/internal" ) @@ -25,6 +27,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIToken string @@ -38,7 +42,7 @@ type Config struct { func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 240*time.Second), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 4*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), @@ -83,6 +87,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } diff --git a/providers/dns/rcodezero/rcodezero.toml b/providers/dns/rcodezero/rcodezero.toml index 7ab451e5f..c2a4a1e7b 100644 --- a/providers/dns/rcodezero/rcodezero.toml +++ b/providers/dns/rcodezero/rcodezero.toml @@ -6,7 +6,7 @@ Since = "v4.13" Example = ''' RCODEZERO_API_TOKEN= \ -lego --email you@example.com --dns rcodezero -d '*.example.com' -d example.com run +lego --dns rcodezero -d '*.example.com' -d example.com run ''' Additional = ''' @@ -23,10 +23,10 @@ RcodeZero is an Anycast Network so the distribution of the DNS01-Challenge can t [Configuration.Credentials] RCODEZERO_API_TOKEN = "API token" [Configuration.Additional] - RCODEZERO_POLLING_INTERVAL = "Time between DNS propagation check" - RCODEZERO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - RCODEZERO_TTL = "The TTL of the TXT record used for the DNS challenge" - RCODEZERO_HTTP_TIMEOUT = "API request timeout" + RCODEZERO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + RCODEZERO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 240)" + RCODEZERO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + RCODEZERO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] # Note: the API endpoint used inside the client is not documented. diff --git a/providers/dns/rcodezero/rcodezero_test.go b/providers/dns/rcodezero/rcodezero_test.go index 1f0946072..a4a242c30 100644 --- a/providers/dns/rcodezero/rcodezero_test.go +++ b/providers/dns/rcodezero/rcodezero_test.go @@ -37,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -94,6 +95,7 @@ func TestLivePresentAndCleanup(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/regfish/regfish.go b/providers/dns/regfish/regfish.go index 306c59bdd..85aac92e5 100644 --- a/providers/dns/regfish/regfish.go +++ b/providers/dns/regfish/regfish.go @@ -8,8 +8,10 @@ import ( "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" regfishapi "github.com/regfish/regfish-dnsapi-go" ) @@ -25,6 +27,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -81,6 +85,15 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client := regfishapi.NewClient(config.APIKey) + if config.HTTPClient != nil { + client.Client = config.HTTPClient + } else { + // Because the regfishapi.NewClient uses an empty http.Client. + client.Client = &http.Client{Timeout: 30 * time.Second} + } + + client.Client = clientdebug.Wrap(client.Client) + return &DNSProvider{ config: config, client: client, @@ -119,6 +132,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("regfish: unknown record ID for '%s'", info.EffectiveFQDN) } diff --git a/providers/dns/regfish/regfish.toml b/providers/dns/regfish/regfish.toml index fbc4bdd70..fbaacbde4 100644 --- a/providers/dns/regfish/regfish.toml +++ b/providers/dns/regfish/regfish.toml @@ -6,17 +6,17 @@ Since = "v4.20.0" Example = ''' REGFISH_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ -lego --email you@example.com --dns regfish -d '*.example.com' -d example.com run +lego --dns regfish -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] REGFISH_API_KEY = "API key" [Configuration.Additional] - REGFISH_POLLING_INTERVAL = "Time between DNS propagation check" - REGFISH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - REGFISH_TTL = "The TTL of the TXT record used for the DNS challenge" - REGFISH_HTTP_TIMEOUT = "API request timeout" + REGFISH_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + REGFISH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + REGFISH_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + REGFISH_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://regfish.readme.io/" diff --git a/providers/dns/regfish/regfish_test.go b/providers/dns/regfish/regfish_test.go index 80928048f..6613bd508 100644 --- a/providers/dns/regfish/regfish_test.go +++ b/providers/dns/regfish/regfish_test.go @@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -92,6 +93,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -105,6 +107,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/regru/internal/client.go b/providers/dns/regru/internal/client.go index 7ce633b05..b0b86d567 100644 --- a/providers/dns/regru/internal/client.go +++ b/providers/dns/regru/internal/client.go @@ -38,7 +38,7 @@ func NewClient(username, password string) *Client { // RemoveTxtRecord removes a TXT record. // https://www.reg.ru/support/help/api2#zone_remove_record -func (c Client) RemoveTxtRecord(ctx context.Context, domain, subDomain, content string) error { +func (c *Client) RemoveTxtRecord(ctx context.Context, domain, subDomain, content string) error { request := RemoveRecordRequest{ Domains: []Domain{{DName: domain}}, SubDomain: subDomain, @@ -57,7 +57,7 @@ func (c Client) RemoveTxtRecord(ctx context.Context, domain, subDomain, content // AddTXTRecord adds a TXT record. // https://www.reg.ru/support/help/api2#zone_add_txt -func (c Client) AddTXTRecord(ctx context.Context, domain, subDomain, content string) error { +func (c *Client) AddTXTRecord(ctx context.Context, domain, subDomain, content string) error { request := AddTxtRequest{ Domains: []Domain{{DName: domain}}, SubDomain: subDomain, @@ -73,7 +73,7 @@ func (c Client) AddTXTRecord(ctx context.Context, domain, subDomain, content str return resp.HasError() } -func (c Client) doRequest(ctx context.Context, request any, fragments ...string) (*APIResponse, error) { +func (c *Client) doRequest(ctx context.Context, request any, fragments ...string) (*APIResponse, error) { endpoint := c.baseURL.JoinPath(fragments...) inputData, err := json.Marshal(request) @@ -111,6 +111,7 @@ func (c Client) doRequest(ctx context.Context, request any, fragments ...string) } var apiResp APIResponse + err = json.Unmarshal(raw, &apiResp) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) @@ -123,6 +124,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIResponse + err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/regru/internal/client_test.go b/providers/dns/regru/internal/client_test.go index fa3f16702..002da0185 100644 --- a/providers/dns/regru/internal/client_test.go +++ b/providers/dns/regru/internal/client_test.go @@ -1,61 +1,60 @@ package internal import ( - "context" - "net/http" + "net/http/httptest" "net/url" - "os" "testing" - "time" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -const ( - noopBaseURL = "https://api.reg.ru/api/regru2/nop" - officialTestUser = "test" - officialTestPassword = "test" -) +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + ) +} func TestRemoveRecord(t *testing.T) { - // TODO(ldez): remove skip when the reg.ru API will be fixed. - t.Skip("there is a bug with the reg.ru API: INTERNAL_API_ERROR: Внутренняя ошибка, status code: 503") + client := mockBuilder(). + Route("POST /zone/remove_record", + servermock.ResponseFromFixture("remove_record.json"), + servermock.CheckForm().Strict(). + With("input_data", `{"domains":[{"dname":"test.ru"}],"subdomain":"_acme-challenge","content":"txttxttxt","record_type":"TXT","output_content_type":"plain"}`). + With("username", "user"). + With("password", "secret"). + With("input_format", "json")). + Build(t) - client := NewClient(officialTestUser, officialTestPassword) - client.HTTPClient = &http.Client{Timeout: 30 * time.Second} - - err := client.RemoveTxtRecord(context.Background(), "test.ru", "_acme-challenge", "txttxttxt") + err := client.RemoveTxtRecord(t.Context(), "test.ru", "_acme-challenge", "txttxttxt") require.NoError(t, err) } func TestRemoveRecord_errors(t *testing.T) { - // TODO(ldez): remove skip when the reg.ru API will be fixed. - if os.Getenv("CI") == "true" { - t.Skip("there is a bug with the reg.ru and GitHub action: dial tcp 194.58.116.30:443: i/o timeout") - } - testCases := []struct { desc string domain string - username string - password string - baseURL string + response string expected string }{ { desc: "authentication failed", domain: "test.ru", - username: "", - password: "", - baseURL: noopBaseURL, + response: "remove_record_error_auth.json", expected: "API error: NO_AUTH: No authorization mechanism selected", }, { desc: "domain error", domain: "", - username: officialTestUser, - password: officialTestPassword, - baseURL: defaultBaseURL, + response: "remove_record_error_domain.json", expected: "API error: NO_DOMAIN: domain_name not given or empty", }, } @@ -64,55 +63,48 @@ func TestRemoveRecord_errors(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client := NewClient(test.username, test.username) - client.HTTPClient = &http.Client{Timeout: 30 * time.Second} - client.baseURL, _ = url.Parse(test.baseURL) + client := mockBuilder(). + Route("POST /zone/remove_record", servermock.ResponseFromFixture(test.response)). + Build(t) - err := client.RemoveTxtRecord(context.Background(), test.domain, "_acme-challenge", "txttxttxt") + err := client.RemoveTxtRecord(t.Context(), test.domain, "_acme-challenge", "txttxttxt") require.EqualError(t, err, test.expected) }) } } func TestAddTXTRecord(t *testing.T) { - // TODO(ldez): remove skip when the reg.ru API will be fixed. - t.Skip("there is a bug with the reg.ru API: INTERNAL_API_ERROR: Внутренняя ошибка, status code: 503") + client := mockBuilder(). + Route("POST /zone/add_txt", + servermock.ResponseFromFixture("add_txt_record.json"), + servermock.CheckForm().Strict(). + With("input_data", `{"domains":[{"dname":"test.ru"}],"subdomain":"_acme-challenge","text":"txttxttxt","output_content_type":"plain"}`). + With("username", "user"). + With("password", "secret"). + With("input_format", "json")). + Build(t) - client := NewClient(officialTestUser, officialTestPassword) - client.HTTPClient = &http.Client{Timeout: 30 * time.Second} - - err := client.AddTXTRecord(context.Background(), "test.ru", "_acme-challenge", "txttxttxt") + err := client.AddTXTRecord(t.Context(), "test.ru", "_acme-challenge", "txttxttxt") require.NoError(t, err) } func TestAddTXTRecord_errors(t *testing.T) { - // TODO(ldez): remove skip when the reg.ru API will be fixed. - if os.Getenv("CI") == "true" { - t.Skip("there is a bug with the reg.ru and GitHub action: dial tcp 194.58.116.30:443: i/o timeout") - } - testCases := []struct { desc string domain string - username string - password string - baseURL string + response string expected string }{ { desc: "authentication failed", domain: "test.ru", - username: "", - password: "", - baseURL: noopBaseURL, + response: "add_txt_record_error_auth.json", expected: "API error: NO_AUTH: No authorization mechanism selected", }, { desc: "domain error", domain: "", - username: officialTestUser, - password: officialTestPassword, - baseURL: defaultBaseURL, + response: "add_txt_record_error_domain.json", expected: "API error: NO_DOMAIN: domain_name not given or empty", }, } @@ -121,11 +113,11 @@ func TestAddTXTRecord_errors(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client := NewClient(test.username, test.username) - client.HTTPClient = &http.Client{Timeout: 30 * time.Second} - client.baseURL, _ = url.Parse(test.baseURL) + client := mockBuilder(). + Route("POST /zone/add_txt", servermock.ResponseFromFixture(test.response)). + Build(t) - err := client.AddTXTRecord(context.Background(), test.domain, "_acme-challenge", "txttxttxt") + err := client.AddTXTRecord(t.Context(), test.domain, "_acme-challenge", "txttxttxt") require.EqualError(t, err, test.expected) }) } diff --git a/providers/dns/regru/internal/fixtures/add_txt_record.json b/providers/dns/regru/internal/fixtures/add_txt_record.json new file mode 100644 index 000000000..06306b4c4 --- /dev/null +++ b/providers/dns/regru/internal/fixtures/add_txt_record.json @@ -0,0 +1,14 @@ +{ + "answer": { + "domains": [ + { + "dname": "test.ru", + "result": "success", + "service_id": 12345 + } + ] + }, + "charset": "utf-8", + "messagestore": null, + "result": "success" +} diff --git a/providers/dns/regru/internal/fixtures/add_txt_record_error_auth.json b/providers/dns/regru/internal/fixtures/add_txt_record_error_auth.json new file mode 100644 index 000000000..2d5314bf3 --- /dev/null +++ b/providers/dns/regru/internal/fixtures/add_txt_record_error_auth.json @@ -0,0 +1,10 @@ +{ + "charset": "utf-8", + "error_code": "NO_AUTH", + "error_params": { + "command_name": "nop/zone/add_txt" + }, + "error_text": "No authorization mechanism selected", + "messagestore": null, + "result": "error" +} diff --git a/providers/dns/regru/internal/fixtures/add_txt_record_error_domain.json b/providers/dns/regru/internal/fixtures/add_txt_record_error_domain.json new file mode 100644 index 000000000..305846ed1 --- /dev/null +++ b/providers/dns/regru/internal/fixtures/add_txt_record_error_domain.json @@ -0,0 +1,14 @@ +{ + "answer": { + "domains": [ + { + "error_code": "NO_DOMAIN", + "error_text": "domain_name not given or empty", + "result": "error" + } + ] + }, + "charset": "utf-8", + "messagestore": null, + "result": "success" +} diff --git a/providers/dns/regru/internal/fixtures/remove_record.json b/providers/dns/regru/internal/fixtures/remove_record.json new file mode 100644 index 000000000..06306b4c4 --- /dev/null +++ b/providers/dns/regru/internal/fixtures/remove_record.json @@ -0,0 +1,14 @@ +{ + "answer": { + "domains": [ + { + "dname": "test.ru", + "result": "success", + "service_id": 12345 + } + ] + }, + "charset": "utf-8", + "messagestore": null, + "result": "success" +} diff --git a/providers/dns/regru/internal/fixtures/remove_record_error_auth.json b/providers/dns/regru/internal/fixtures/remove_record_error_auth.json new file mode 100644 index 000000000..98c429c53 --- /dev/null +++ b/providers/dns/regru/internal/fixtures/remove_record_error_auth.json @@ -0,0 +1,10 @@ +{ + "charset" : "utf-8", + "error_code" : "NO_AUTH", + "error_params" : { + "command_name" : "nop/zone/remove_record" + }, + "error_text" : "No authorization mechanism selected", + "messagestore" : null, + "result" : "error" +} diff --git a/providers/dns/regru/internal/fixtures/remove_record_error_domain.json b/providers/dns/regru/internal/fixtures/remove_record_error_domain.json new file mode 100644 index 000000000..a9ca88ff7 --- /dev/null +++ b/providers/dns/regru/internal/fixtures/remove_record_error_domain.json @@ -0,0 +1,14 @@ +{ + "answer" : { + "domains" : [ + { + "error_code" : "NO_DOMAIN", + "error_text" : "domain_name not given or empty", + "result" : "error" + } + ] + }, + "charset" : "utf-8", + "messagestore" : null, + "result" : "success" +} diff --git a/providers/dns/regru/internal/readme.md b/providers/dns/regru/internal/readme.md new file mode 100644 index 000000000..5f13012d2 --- /dev/null +++ b/providers/dns/regru/internal/readme.md @@ -0,0 +1,6 @@ +Test account (with the default endpoint): +- user: `test` +- password: `test` + +Noop endpoint: +- https://api.reg.ru/api/regru2/nop diff --git a/providers/dns/regru/regru.go b/providers/dns/regru/regru.go index 144b7faf9..b06b355c1 100644 --- a/providers/dns/regru/regru.go +++ b/providers/dns/regru/regru.go @@ -9,8 +9,10 @@ import ( "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/regru/internal" ) @@ -29,6 +31,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Username string @@ -94,6 +98,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + if config.TLSCert != "" || config.TLSKey != "" { if config.TLSCert == "" { return nil, errors.New("regru: TLS certificate is missing") diff --git a/providers/dns/regru/regru.toml b/providers/dns/regru/regru.toml index 16d8e4e3a..728bb2bf7 100644 --- a/providers/dns/regru/regru.toml +++ b/providers/dns/regru/regru.toml @@ -7,7 +7,7 @@ Since = "v3.5.0" Example = ''' REGRU_USERNAME=xxxxxx \ REGRU_PASSWORD=yyyyyy \ -lego --email you@example.com --dns regru -d '*.example.com' -d example.com run +lego --dns regru -d '*.example.com' -d example.com run ''' [Configuration] @@ -17,10 +17,10 @@ lego --email you@example.com --dns regru -d '*.example.com' -d example.com run [Configuration.Additional] REGRU_TLS_CERT = "authentication certificate" REGRU_TLS_KEY = "authentication private key" - REGRU_POLLING_INTERVAL = "Time between DNS propagation check" - REGRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - REGRU_TTL = "The TTL of the TXT record used for the DNS challenge" - REGRU_HTTP_TIMEOUT = "API request timeout" + REGRU_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + REGRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + REGRU_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + REGRU_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.reg.ru/support/help/api2" diff --git a/providers/dns/regru/regru_test.go b/providers/dns/regru/regru_test.go index 15d86d75c..762eeb4d3 100644 --- a/providers/dns/regru/regru_test.go +++ b/providers/dns/regru/regru_test.go @@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -129,6 +130,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -142,6 +144,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/rfc2136/rfc2136.go b/providers/dns/rfc2136/rfc2136.go index bd1d58a0c..2c4fe7aeb 100644 --- a/providers/dns/rfc2136/rfc2136.go +++ b/providers/dns/rfc2136/rfc2136.go @@ -8,6 +8,7 @@ import ( "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/rfc2136/internal" @@ -33,6 +34,8 @@ const ( EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Nameserver string @@ -55,8 +58,8 @@ func NewDefaultConfig() *Config { return &Config{ TSIGAlgorithm: env.GetOrDefaultString(EnvTSIGAlgorithm, dns.HmacSHA1), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, env.GetOrDefaultSecond("RFC2136_TIMEOUT", 60*time.Second)), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, env.GetOrDefaultSecond("RFC2136_TIMEOUT", dns01.DefaultPropagationTimeout)), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), DNSTimeout: env.GetOrDefaultSecond(EnvDNSTimeout, 10*time.Second), } @@ -128,7 +131,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { config.TSIGSecret = "" } else { // zonename must be in canonical form (lowercase, fqdn, see RFC 4034 Section 6.2) - config.TSIGKey = strings.ToLower(dns.Fqdn(config.TSIGKey)) + config.TSIGKey = dns.CanonicalName(config.TSIGKey) } if config.TSIGAlgorithm == "" { @@ -168,6 +171,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("rfc2136: failed to insert: %w", err) } + return nil } @@ -179,6 +183,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("rfc2136: failed to remove: %w", err) } + return nil } @@ -190,14 +195,14 @@ func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { } // Create RR - rr := new(dns.TXT) - rr.Hdr = dns.RR_Header{Name: fqdn, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: uint32(ttl)} - rr.Txt = []string{value} - rrs := []dns.RR{rr} + rrs := []dns.RR{&dns.TXT{ + Hdr: dns.RR_Header{Name: fqdn, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: uint32(ttl)}, + Txt: []string{value}, + }} // Create dynamic update packet - m := new(dns.Msg) - m.SetUpdate(zone) + m := new(dns.Msg).SetUpdate(zone) + switch action { case "INSERT": // Always remove old challenge left over from who knows what. @@ -225,6 +230,7 @@ func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { if err != nil { return fmt.Errorf("DNS update failed: %w", err) } + if reply != nil && reply.Rcode != dns.RcodeSuccess { return fmt.Errorf("DNS update failed: server replied: %s", dns.RcodeToString[reply.Rcode]) } diff --git a/providers/dns/rfc2136/rfc2136.toml b/providers/dns/rfc2136/rfc2136.toml index df313fde7..6b5bbe599 100644 --- a/providers/dns/rfc2136/rfc2136.toml +++ b/providers/dns/rfc2136/rfc2136.toml @@ -9,7 +9,7 @@ RFC2136_NAMESERVER=127.0.0.1 \ RFC2136_TSIG_KEY=example.com \ RFC2136_TSIG_ALGORITHM=hmac-sha256. \ RFC2136_TSIG_SECRET=YWJjZGVmZGdoaWprbG1ub3BxcnN0dXZ3eHl6MTIzNDU= \ -lego --email you@example.com --dns rfc2136 -d '*.example.com' -d example.com run +lego --dns rfc2136 -d '*.example.com' -d example.com run ## --- @@ -17,7 +17,7 @@ keyname=example.com; keyfile=example.com.key; tsig-keygen $keyname > $keyfile RFC2136_NAMESERVER=127.0.0.1 \ RFC2136_TSIG_FILE="$keyfile" \ -lego --email you@example.com --dns rfc2136 -d '*.example.com' -d example.com run +lego --dns rfc2136 -d '*.example.com' -d example.com run ''' [Configuration] @@ -28,11 +28,11 @@ lego --email you@example.com --dns rfc2136 -d '*.example.com' -d example.com run RFC2136_NAMESERVER = 'Network address in the form "host" or "host:port"' [Configuration.Additional] RFC2136_TSIG_FILE = "Path to a key file generated by tsig-keygen" - RFC2136_POLLING_INTERVAL = "Time between DNS propagation check" - RFC2136_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - RFC2136_TTL = "The TTL of the TXT record used for the DNS challenge" - RFC2136_DNS_TIMEOUT = "API request timeout" - RFC2136_SEQUENCE_INTERVAL = "Time between sequential requests" + RFC2136_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + RFC2136_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + RFC2136_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + RFC2136_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" + RFC2136_DNS_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://www.rfc-editor.org/rfc/rfc2136.html" diff --git a/providers/dns/rfc2136/rfc2136_test.go b/providers/dns/rfc2136/rfc2136_test.go index 80fdc69cb..ce4859e84 100644 --- a/providers/dns/rfc2136/rfc2136_test.go +++ b/providers/dns/rfc2136/rfc2136_test.go @@ -2,24 +2,21 @@ package rfc2136 import ( "bytes" - "fmt" - "net" "strings" - "sync" "testing" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/dnsmock" "github.com/miekg/dns" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( fakeDomain = "123456789.www.example.com" fakeKeyAuth = "123d==" - fakeValue = "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo" + fakeValue = "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" fakeFqdn = "_acme-challenge.123456789.www.example.com." fakeZone = "example.com." fakeTTL = 120 @@ -87,6 +84,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -163,39 +161,16 @@ func TestNewDNSProviderConfig(t *testing.T) { } } -func TestCanaryLocalTestServer(t *testing.T) { +func TestDNSProvider_Present_success(t *testing.T) { dns01.ClearFqdnCache() - dns.HandleFunc("example.com.", serverHandlerHello) - defer dns.HandleRemove("example.com.") - server, addr, err := runLocalDNSTestServer(false) - require.NoError(t, err, "Failed to start test server") - defer func() { _ = server.Shutdown() }() - - c := new(dns.Client) - m := new(dns.Msg) - - m.SetQuestion("example.com.", dns.TypeTXT) - - r, _, err := c.Exchange(m, addr) - require.NoError(t, err, "Failed to communicate with test server") - assert.Len(t, r.Extra, 1, "Failed to communicate with test server") - - txt := r.Extra[0].(*dns.TXT).Txt[0] - assert.Equal(t, "Hello world", txt) -} - -func TestServerSuccess(t *testing.T) { - dns01.ClearFqdnCache() - dns.HandleFunc(fakeZone, serverHandlerReturnSuccess) - defer dns.HandleRemove(fakeZone) - - server, addr, err := runLocalDNSTestServer(false) - require.NoError(t, err, "Failed to start test server") - defer func() { _ = server.Shutdown() }() + addr := dnsmock.NewServer(). + Query(fakeZone+" SOA", dnsmock.SOA("")). + Update(fakeZone+" SOA", dnsmock.Noop). + Build(t) config := NewDefaultConfig() - config.Nameserver = addr + config.Nameserver = addr.String() provider, err := NewDNSProviderConfig(config) require.NoError(t, err) @@ -204,39 +179,98 @@ func TestServerSuccess(t *testing.T) { require.NoError(t, err) } -func TestServerError(t *testing.T) { +func TestDNSProvider_Present_success_updatePacket(t *testing.T) { dns01.ClearFqdnCache() - dns.HandleFunc(fakeZone, serverHandlerReturnErr) - defer dns.HandleRemove(fakeZone) - server, addr, err := runLocalDNSTestServer(false) - require.NoError(t, err, "Failed to start test server") - defer func() { _ = server.Shutdown() }() + reqChan := make(chan *dns.Msg, 1) + + addr := dnsmock.NewServer(). + Query("_acme-challenge.123456789.www.example.com. SOA", dnsmock.SOA(fakeZone)). + Update(fakeZone+" SOA", func(w dns.ResponseWriter, req *dns.Msg) { + dnsmock.Noop(w, req) + + // Only talk back when it is not the SOA RR. + reqChan <- req + }). + Build(t) config := NewDefaultConfig() - config.Nameserver = addr + config.Nameserver = addr.String() + + provider, err := NewDNSProviderConfig(config) + require.NoError(t, err) + + err = provider.Present(fakeDomain, "", fakeKeyAuth) + require.NoError(t, err) + + select { + case <-time.After(time.Second): + t.Fatal("timeout waiting for request") + + case rcvMsg := <-reqChan: + txtRR := &dns.TXT{ + Hdr: dns.RR_Header{Name: fakeFqdn, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: fakeTTL}, + Txt: []string{fakeValue}, + } + + m := new(dns.Msg).SetUpdate(fakeZone) + + m.RemoveRRset([]dns.RR{txtRR}) + m.Insert([]dns.RR{txtRR}) + + expected, err := m.Pack() + require.NoError(t, err, "error packing") + + rcvMsg.Id = m.Id + + actual, err := rcvMsg.Pack() + require.NoError(t, err, "error packing") + + if !bytes.Equal(actual, expected) { + tmp := new(dns.Msg) + require.NoError(t, tmp.Unpack(actual)) + + t.Errorf("Expected msg:\n%s", m) + t.Errorf("Actual msg:\n%s", tmp) + } + } +} + +func TestDNSProvider_Present_error(t *testing.T) { + dns01.ClearFqdnCache() + + addr := dnsmock.NewServer(). + Query(fakeZone+" SOA", dnsmock.Error(dns.RcodeNotZone)). + Build(t) + + config := NewDefaultConfig() + config.Nameserver = addr.String() provider, err := NewDNSProviderConfig(config) require.NoError(t, err) err = provider.Present(fakeDomain, "", fakeKeyAuth) require.Error(t, err) + if !strings.Contains(err.Error(), "NOTZONE") { t.Errorf("Expected Present() to return an error with the 'NOTZONE' rcode string, but it did not: %v", err) } } -func TestTsigClient(t *testing.T) { +func TestDNSProvider_Present_tsig_success(t *testing.T) { dns01.ClearFqdnCache() - dns.HandleFunc(fakeZone, serverHandlerReturnSuccess) - defer dns.HandleRemove(fakeZone) - server, addr, err := runLocalDNSTestServer(true) - require.NoError(t, err, "Failed to start test server") - defer func() { _ = server.Shutdown() }() + addr := dnsmock.NewServer(). + Query(fakeZone+" SOA", dnsmock.SOA("")). + Update(fakeZone+" SOA", handleTSIG). + Build(t, func(server *dns.Server) error { + server.TsigSecret = map[string]string{fakeTsigKey: fakeTsigSecret} + + return nil + }) config := NewDefaultConfig() - config.Nameserver = addr + config.Nameserver = addr.String() config.TSIGKey = fakeTsigKey config.TSIGSecret = fakeTsigSecret @@ -247,143 +281,50 @@ func TestTsigClient(t *testing.T) { require.NoError(t, err) } -func TestValidUpdatePacket(t *testing.T) { - reqChan := make(chan *dns.Msg, 10) - +func TestDNSProvider_Present_tsig_error(t *testing.T) { dns01.ClearFqdnCache() - dns.HandleFunc(fakeZone, serverHandlerPassBackRequest(reqChan)) - defer dns.HandleRemove(fakeZone) - server, addr, err := runLocalDNSTestServer(false) - require.NoError(t, err, "Failed to start test server") - defer func() { _ = server.Shutdown() }() + addr := dnsmock.NewServer(). + Query(fakeZone+" SOA", dnsmock.SOA("")). + Update(fakeZone+" SOA", handleTSIG). + Build(t, func(server *dns.Server) error { + server.TsigSecret = map[string]string{"example.org": fakeTsigSecret} - txtRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN TXT %s", fakeFqdn, fakeTTL, fakeValue)) - rrs := []dns.RR{txtRR} - m := new(dns.Msg) - m.SetUpdate(fakeZone) - m.RemoveRRset(rrs) - m.Insert(rrs) - expectStr := m.String() - - expect, err := m.Pack() - require.NoError(t, err, "error packing") + return nil + }) config := NewDefaultConfig() - config.Nameserver = addr + config.Nameserver = addr.String() + config.TSIGKey = fakeTsigKey + config.TSIGSecret = fakeTsigSecret provider, err := NewDNSProviderConfig(config) require.NoError(t, err) - err = provider.Present(fakeDomain, "", "1234d==") - require.NoError(t, err) - - rcvMsg := <-reqChan - rcvMsg.Id = m.Id - - actual, err := rcvMsg.Pack() - require.NoError(t, err, "error packing") - - if !bytes.Equal(actual, expect) { - tmp := new(dns.Msg) - if err := tmp.Unpack(actual); err != nil { - t.Fatalf("Error unpacking actual msg: %v", err) - } - t.Errorf("Expected msg:\n%s", expectStr) - t.Errorf("Actual msg:\n%v", tmp) - } + err = provider.Present(fakeDomain, "", fakeKeyAuth) + require.Error(t, err) + require.EqualError(t, err, "rfc2136: failed to insert: DNS update failed: server replied: NOTZONE") } -func runLocalDNSTestServer(tsig bool) (*dns.Server, string, error) { - pc, err := net.ListenPacket("udp", "127.0.0.1:0") +func handleTSIG(w dns.ResponseWriter, req *dns.Msg) { + m := new(dns.Msg) + + tsig := req.IsTsig() + if tsig == nil { + _ = w.WriteMsg(m.SetRcode(req, dns.RcodeRefused)) + return + } + + err := w.TsigStatus() if err != nil { - return nil, "", err + _ = w.WriteMsg(m.SetRcode(req, dns.RcodeNotZone)) + + return } - server := &dns.Server{ - PacketConn: pc, - ReadTimeout: time.Hour, - WriteTimeout: time.Hour, - MsgAcceptFunc: func(dh dns.Header) dns.MsgAcceptAction { - // bypass defaultMsgAcceptFunc to allow dynamic update (https://github.com/miekg/dns/pull/830) - return dns.MsgAccept - }, - } - - if tsig { - server.TsigSecret = map[string]string{fakeTsigKey: fakeTsigSecret} - } - - waitLock := sync.Mutex{} - waitLock.Lock() - server.NotifyStartedFunc = waitLock.Unlock - - go func() { - _ = server.ActivateAndServe() - pc.Close() - }() - - waitLock.Lock() - return server, pc.LocalAddr().String(), nil -} - -func serverHandlerHello(w dns.ResponseWriter, req *dns.Msg) { - m := new(dns.Msg) - m.SetReply(req) - m.Extra = make([]dns.RR, 1) - m.Extra[0] = &dns.TXT{ - Hdr: dns.RR_Header{Name: m.Question[0].Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 0}, - Txt: []string{"Hello world"}, - } - _ = w.WriteMsg(m) -} - -func serverHandlerReturnSuccess(w dns.ResponseWriter, req *dns.Msg) { - m := new(dns.Msg) - m.SetReply(req) - if req.Opcode == dns.OpcodeQuery && req.Question[0].Qtype == dns.TypeSOA && req.Question[0].Qclass == dns.ClassINET { - // Return SOA to appease findZoneByFqdn() - soaRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN SOA ns1.%s admin.%s 2016022801 28800 7200 2419200 1200", fakeZone, fakeTTL, fakeZone, fakeZone)) - m.Answer = []dns.RR{soaRR} - } - - if t := req.IsTsig(); t != nil { - if w.TsigStatus() == nil { - // Validated - m.SetTsig(fakeZone, dns.HmacSHA1, 300, time.Now().Unix()) - } - } - - _ = w.WriteMsg(m) -} - -func serverHandlerReturnErr(w dns.ResponseWriter, req *dns.Msg) { - m := new(dns.Msg) - m.SetRcode(req, dns.RcodeNotZone) - _ = w.WriteMsg(m) -} - -func serverHandlerPassBackRequest(reqChan chan *dns.Msg) func(w dns.ResponseWriter, req *dns.Msg) { - return func(w dns.ResponseWriter, req *dns.Msg) { - m := new(dns.Msg) - m.SetReply(req) - if req.Opcode == dns.OpcodeQuery && req.Question[0].Qtype == dns.TypeSOA && req.Question[0].Qclass == dns.ClassINET { - // Return SOA to appease findZoneByFqdn() - soaRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN SOA ns1.%s admin.%s 2016022801 28800 7200 2419200 1200", fakeZone, fakeTTL, fakeZone, fakeZone)) - m.Answer = []dns.RR{soaRR} - } - - if t := req.IsTsig(); t != nil { - if w.TsigStatus() == nil { - // Validated - m.SetTsig(fakeZone, dns.HmacSHA1, 300, time.Now().Unix()) - } - } - - _ = w.WriteMsg(m) - if req.Opcode != dns.OpcodeQuery || req.Question[0].Qtype != dns.TypeSOA || req.Question[0].Qclass != dns.ClassINET { - // Only talk back when it is not the SOA RR. - reqChan <- req - } - } + // Validated + _ = w.WriteMsg(m. + SetReply(req). + SetTsig(tsig.Hdr.Name, tsig.Algorithm, tsig.Fudge, time.Now().Unix()), + ) } diff --git a/providers/dns/rimuhosting/rimuhosting.go b/providers/dns/rimuhosting/rimuhosting.go index 09b31d4f5..7a7e99f60 100644 --- a/providers/dns/rimuhosting/rimuhosting.go +++ b/providers/dns/rimuhosting/rimuhosting.go @@ -2,12 +2,12 @@ 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/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/rimuhosting" @@ -25,20 +25,15 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) -// Config is used to configure the creation of the DNSProvider. -type Config struct { - APIKey string +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) - PropagationTimeout time.Duration - PollingInterval time.Duration - TTL int - HTTPClient *http.Client -} +// Config is used to configure the creation of the DNSProvider. +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{ @@ -49,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. @@ -73,48 +67,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("rimuhosting: the configuration of the DNS provider is nil") } - if config.APIKey == "" { - return nil, errors.New("rimuhosting: incomplete credentials, missing API key") + provider, err := rimuhosting.NewDNSProviderConfig(config, "") + if err != nil { + return nil, fmt.Errorf("rimuhosting: %w", err) } - client := rimuhosting.NewClient(config.APIKey) - client.BaseURL = rimuhosting.DefaultRimuHostingBaseURL - - if config.HTTPClient != nil { - client.HTTPClient = config.HTTPClient - } - - return &DNSProvider{config: config, client: client}, nil -} - -// Timeout returns the timeout and interval to use when checking for DNS propagation. -// Adjusting here to cope with spikes in propagation times. -func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.config.PropagationTimeout, d.config.PollingInterval + return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - ctx := context.Background() - - records, err := d.client.FindTXTRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN)) + err := d.prv.Present(domain, token, keyAuth) if err != nil { - return fmt.Errorf("rimuhosting: failed to find record(s) for %s: %w", domain, err) - } - - actions := []rimuhosting.ActionParameter{ - rimuhosting.NewAddRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL), - } - - for _, record := range records { - actions = append(actions, rimuhosting.NewAddRecordAction(record.Name, record.Content, d.config.TTL)) - } - - _, err = d.client.DoActions(ctx, actions...) - if err != nil { - return fmt.Errorf("rimuhosting: failed to add record(s) for %s: %w", domain, err) + return fmt.Errorf("rimuhosting: %w", err) } return nil @@ -122,14 +87,16 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - action := rimuhosting.NewDeleteRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value) - - _, err := d.client.DoActions(context.Background(), action) + err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { - return fmt.Errorf("rimuhosting: failed to delete record for %s: %w", domain, err) + return fmt.Errorf("rimuhosting: %w", err) } return nil } + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.prv.Timeout() +} diff --git a/providers/dns/rimuhosting/rimuhosting.toml b/providers/dns/rimuhosting/rimuhosting.toml index 4b4fa5ea7..c1994e2cc 100644 --- a/providers/dns/rimuhosting/rimuhosting.toml +++ b/providers/dns/rimuhosting/rimuhosting.toml @@ -6,17 +6,17 @@ Since = "v0.3.5" Example = ''' RIMUHOSTING_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns rimuhosting -d '*.example.com' -d example.com run +lego --dns rimuhosting -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] RIMUHOSTING_API_KEY = "User API key" [Configuration.Additional] - RIMUHOSTING_POLLING_INTERVAL = "Time between DNS propagation check" - RIMUHOSTING_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - RIMUHOSTING_TTL = "The TTL of the TXT record used for the DNS challenge" - RIMUHOSTING_HTTP_TIMEOUT = "API request timeout" + RIMUHOSTING_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + RIMUHOSTING_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + RIMUHOSTING_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" + RIMUHOSTING_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://rimuhosting.com/dns/dyndns.jsp" diff --git a/providers/dns/rimuhosting/rimuhosting_test.go b/providers/dns/rimuhosting/rimuhosting_test.go index cbdacedc4..878ec14da 100644 --- a/providers/dns/rimuhosting/rimuhosting_test.go +++ b/providers/dns/rimuhosting/rimuhosting_test.go @@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -45,7 +46,7 @@ func TestNewDNSProvider(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - require.NotNil(t, p.config) + require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -83,7 +84,7 @@ func TestNewDNSProviderConfig(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - require.NotNil(t, p.config) + require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -97,6 +98,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -110,6 +112,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/route53/fixtures/changeResourceRecordSetsResponse.xml b/providers/dns/route53/fixtures/changeResourceRecordSetsResponse.xml new file mode 100644 index 000000000..68dba580f --- /dev/null +++ b/providers/dns/route53/fixtures/changeResourceRecordSetsResponse.xml @@ -0,0 +1,8 @@ + + + + /change/123456 + PENDING + 2016-02-10T01:36:41.958Z + + diff --git a/providers/dns/route53/fixtures/getChangeResponse.xml b/providers/dns/route53/fixtures/getChangeResponse.xml new file mode 100644 index 000000000..f22c09460 --- /dev/null +++ b/providers/dns/route53/fixtures/getChangeResponse.xml @@ -0,0 +1,8 @@ + + + + 123456 + INSYNC + 2016-02-10T01:36:41.958Z + + diff --git a/providers/dns/route53/fixtures/listHostedZonesByNameResponse.xml b/providers/dns/route53/fixtures/listHostedZonesByNameResponse.xml new file mode 100644 index 000000000..db47ba1e1 --- /dev/null +++ b/providers/dns/route53/fixtures/listHostedZonesByNameResponse.xml @@ -0,0 +1,19 @@ + + + + + /hostedzone/ABCDEFG + example.com. + D2224C5B-684A-DB4A-BB9A-E09E3BAFEA7A + + Test comment + false + + 10 + + + true + example2.com + ZLT12321321124 + 1 + diff --git a/providers/dns/route53/fixtures_test.go b/providers/dns/route53/fixtures_test.go deleted file mode 100644 index 444a88003..000000000 --- a/providers/dns/route53/fixtures_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package route53 - -const ChangeResourceRecordSetsResponse = ` - - - /change/123456 - PENDING - 2016-02-10T01:36:41.958Z - -` - -const ListHostedZonesByNameResponse = ` - - - - /hostedzone/ABCDEFG - example.com. - D2224C5B-684A-DB4A-BB9A-E09E3BAFEA7A - - Test comment - false - - 10 - - - true - example2.com - ZLT12321321124 - 1 -` - -const GetChangeResponse = ` - - - 123456 - INSYNC - 2016-02-10T01:36:41.958Z - -` diff --git a/providers/dns/route53/mock_test.go b/providers/dns/route53/mock_test.go deleted file mode 100644 index 022767385..000000000 --- a/providers/dns/route53/mock_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package route53 - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// MockResponse represents a predefined response used by a mock server. -type MockResponse struct { - StatusCode int - Body string -} - -// MockResponseMap maps request paths to responses. -type MockResponseMap map[string]MockResponse - -func setupTest(t *testing.T, responses MockResponseMap) string { - t.Helper() - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - resp, ok := responses[path] - if !ok { - resp, ok = responses[r.RequestURI] - if !ok { - msg := fmt.Sprintf("Requested path not found in response map: %s", path) - require.FailNow(t, msg) - } - } - - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(resp.StatusCode) - _, err := w.Write([]byte(resp.Body)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - time.Sleep(100 * time.Millisecond) - - return server.URL -} diff --git a/providers/dns/route53/route53.go b/providers/dns/route53/route53.go index 394aa506d..b41c95dac 100644 --- a/providers/dns/route53/route53.go +++ b/providers/dns/route53/route53.go @@ -17,9 +17,12 @@ import ( "github.com/aws/aws-sdk-go-v2/service/route53" awstypes "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/cenkalti/backoff/v5" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/wait" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" ) // Environment variables names. @@ -33,6 +36,7 @@ const ( EnvMaxRetries = envNamespace + "MAX_RETRIES" EnvAssumeRoleArn = envNamespace + "ASSUME_ROLE_ARN" EnvExternalID = envNamespace + "EXTERNAL_ID" + EnvPrivateZone = envNamespace + "PRIVATE_ZONE" EnvWaitForRecordSetsChanged = envNamespace + "WAIT_FOR_RECORD_SETS_CHANGED" @@ -41,6 +45,8 @@ const ( EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { // Static credential chain. @@ -54,6 +60,7 @@ type Config struct { MaxRetries int AssumeRoleArn string ExternalID string + PrivateZone bool WaitForRecordSetsChanged bool @@ -71,6 +78,7 @@ func NewDefaultConfig() *Config { MaxRetries: env.GetOrDefaultInt(EnvMaxRetries, 5), AssumeRoleArn: env.GetOrDefaultString(EnvAssumeRoleArn, ""), ExternalID: env.GetOrDefaultString(EnvExternalID, ""), + PrivateZone: env.GetOrDefaultBool(EnvPrivateZone, false), WaitForRecordSetsChanged: env.GetOrDefaultBool(EnvWaitForRecordSetsChanged, true), @@ -147,8 +155,9 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { realValue := `"` + info.Value + `"` var found bool + for _, record := range records { - if deref(record.Value) == realValue { + if ptr.Deref(record.Value) == realValue { found = true } } @@ -192,8 +201,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } var nonLegoRecords []awstypes.ResourceRecord + for _, record := range existingRecords { - if deref(record.Value) != `"`+info.Value+`"` { + if ptr.Deref(record.Value) != `"`+info.Value+`"` { nonLegoRecords = append(nonLegoRecords, record) } } @@ -242,18 +252,22 @@ func (d *DNSProvider) changeRecord(ctx context.Context, action awstypes.ChangeAc changeID := resp.ChangeInfo.Id if d.config.WaitForRecordSetsChanged { - return wait.For("route53", d.config.PropagationTimeout, d.config.PollingInterval, func() (bool, error) { - resp, err := d.client.GetChange(ctx, &route53.GetChangeInput{Id: changeID}) - if err != nil { - return false, fmt.Errorf("failed to query change status: %w", err) - } + return wait.Retry(ctx, + func() error { + resp, err := d.client.GetChange(ctx, &route53.GetChangeInput{Id: changeID}) + if err != nil { + return fmt.Errorf("failed to query change status: %w", err) + } - if resp.ChangeInfo.Status == awstypes.ChangeStatusInsync { - return true, nil - } + if resp.ChangeInfo.Status != awstypes.ChangeStatusInsync { + return fmt.Errorf("unable to retrieve change: ID=%s, status=%s", ptr.Deref(changeID), resp.ChangeInfo.Status) + } - return false, fmt.Errorf("unable to retrieve change: ID=%s", deref(changeID)) - }) + return nil + }, + backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)), + backoff.WithMaxElapsedTime(d.config.PropagationTimeout), + ) } return nil @@ -278,7 +292,7 @@ func (d *DNSProvider) getExistingRecordSets(ctx context.Context, hostedZoneID, f var records []awstypes.ResourceRecord for _, recordSet := range recordSetsOutput.ResourceRecordSets { - if deref(recordSet.Name) == fqdn { + if ptr.Deref(recordSet.Name) == fqdn { records = append(records, recordSet.ResourceRecords...) } } @@ -300,16 +314,18 @@ func (d *DNSProvider) getHostedZoneID(ctx context.Context, fqdn string) (string, reqParams := &route53.ListHostedZonesByNameInput{ DNSName: aws.String(dns01.UnFqdn(authZone)), } + resp, err := d.client.ListHostedZonesByName(ctx, reqParams) if err != nil { return "", err } var hostedZoneID string + for _, hostedZone := range resp.HostedZones { // .Name has a trailing dot - if !hostedZone.Config.PrivateZone && deref(hostedZone.Name) == authZone { - hostedZoneID = deref(hostedZone.Id) + if ptr.Deref(hostedZone.Name) == authZone && d.config.PrivateZone == hostedZone.Config.PrivateZone { + hostedZoneID = ptr.Deref(hostedZone.Id) break } } @@ -338,12 +354,10 @@ func createAWSConfig(ctx context.Context, config *Config) (aws.Config, error) { // causing a high number of consecutive throttling errors. // For reference: Route 53 enforces an account-wide(!) 5req/s query limit. options.Backoff = retry.BackoffDelayerFunc(func(attempt int, err error) (time.Duration, error) { - retryCount := attempt - if retryCount > 7 { - retryCount = 7 - } + retryCount := min(attempt, 7) delay := (1 << uint(retryCount)) * (rand.Intn(50) + 200) + return time.Duration(delay) * time.Millisecond, nil }) }) @@ -391,12 +405,3 @@ func createAWSConfigCheckParams(config *Config) error { return nil } - -func deref[T string | int | int32 | int64 | bool](v *T) T { - if v == nil { - var zero T - return zero - } - - return *v -} diff --git a/providers/dns/route53/route53.toml b/providers/dns/route53/route53.toml index 53c1d61d1..607d9ef31 100644 --- a/providers/dns/route53/route53.toml +++ b/providers/dns/route53/route53.toml @@ -9,7 +9,7 @@ AWS_ACCESS_KEY_ID=your_key_id \ AWS_SECRET_ACCESS_KEY=your_secret_access_key \ AWS_REGION=aws-region \ AWS_HOSTED_ZONE_ID=your_hosted_zone_id \ -lego --email you@example.com --dns route53 -d '*.example.com' -d example.com run +lego --dns route53 -d '*.example.com' -d example.com run ''' Additional = ''' @@ -133,11 +133,12 @@ Replace `Z11111112222222333333` with your hosted zone ID and `example.com` with AWS_EXTERNAL_ID = "Managed by STS AssumeRole API operation (`AWS_EXTERNAL_ID_FILE` is not supported)" AWS_WAIT_FOR_RECORD_SETS_CHANGED = "Wait for changes to be INSYNC (it can be unstable)" [Configuration.Additional] + AWS_PRIVATE_ZONE = "Set to true to use private zones only (default: use public zones only)" AWS_SHARED_CREDENTIALS_FILE = "Managed by the AWS client. Shared credentials file." AWS_MAX_RETRIES = "The number of maximum returns the service will use to make an individual API request" - AWS_POLLING_INTERVAL = "Time between DNS propagation check" - AWS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - AWS_TTL = "The TTL of the TXT record used for the DNS challenge" + AWS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)" + AWS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + AWS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)" [Links] API = "https://docs.aws.amazon.com/Route53/latest/APIReference/API_Operations_Amazon_Route_53.html" diff --git a/providers/dns/route53/route53_integration_test.go b/providers/dns/route53/route53_integration_test.go index 2fbcf5206..b80294013 100644 --- a/providers/dns/route53/route53_integration_test.go +++ b/providers/dns/route53/route53_integration_test.go @@ -1,12 +1,12 @@ package route53 import ( - "context" "testing" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/route53" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" "github.com/stretchr/testify/require" ) @@ -28,7 +28,7 @@ func TestLiveTTL(t *testing.T) { // we need a separate R53 client here as the one in the DNS provider is unexported. fqdn := "_acme-challenge." + domain + "." - ctx := context.Background() + ctx := t.Context() cfg, err := awsconfig.LoadDefaultConfig(ctx) require.NoError(t, err) @@ -42,7 +42,7 @@ func TestLiveTTL(t *testing.T) { } }() - zoneID, err := provider.getHostedZoneID(context.Background(), fqdn) + zoneID, err := provider.getHostedZoneID(t.Context(), fqdn) require.NoError(t, err) params := &route53.ListResourceRecordSetsInput{ @@ -52,7 +52,7 @@ func TestLiveTTL(t *testing.T) { require.NoError(t, err) for _, v := range resp.ResourceRecordSets { - if deref(v.Name) == fqdn && v.Type == "TXT" && deref(v.TTL) == 10 { + if ptr.Deref(v.Name) == fqdn && v.Type == "TXT" && ptr.Deref(v.TTL) == 10 { return } } diff --git a/providers/dns/route53/route53_test.go b/providers/dns/route53/route53_test.go index 1c835ac37..41ed824bc 100644 --- a/providers/dns/route53/route53_test.go +++ b/providers/dns/route53/route53_test.go @@ -1,7 +1,7 @@ package route53 import ( - "context" + "net/http/httptest" "os" "testing" "time" @@ -11,6 +11,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -23,6 +24,7 @@ var envTest = tester.NewEnvTest( EnvRegion, EnvHostedZoneID, EnvMaxRetries, + EnvPrivateZone, EnvTTL, EnvPropagationTimeout, EnvPollingInterval, @@ -30,31 +32,16 @@ var envTest = tester.NewEnvTest( WithDomain(envDomain). WithLiveTestRequirements(EnvAccessKeyID, EnvSecretAccessKey, EnvRegion, envDomain) -func makeTestProvider(t *testing.T, serverURL string) *DNSProvider { - t.Helper() - - cfg := aws.Config{ - Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "), - Region: "mock-region", - BaseEndpoint: aws.String(serverURL), - RetryMaxAttempts: 1, - } - - return &DNSProvider{ - client: route53.NewFromConfig(cfg), - config: NewDefaultConfig(), - } -} - func Test_loadCredentials_FromEnv(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() _ = os.Setenv(EnvAccessKeyID, "123") _ = os.Setenv(EnvSecretAccessKey, "456") _ = os.Setenv(EnvRegion, "us-east-1") - ctx := context.Background() + ctx := t.Context() cfg, err := awsconfig.LoadDefaultConfig(ctx) require.NoError(t, err) @@ -74,11 +61,12 @@ func Test_loadCredentials_FromEnv(t *testing.T) { func Test_loadRegion_FromEnv(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() _ = os.Setenv(EnvRegion, "foo") - cfg, err := awsconfig.LoadDefaultConfig(context.Background()) + cfg, err := awsconfig.LoadDefaultConfig(t.Context()) require.NoError(t, err) assert.Equal(t, "foo", cfg.Region, "Region") @@ -86,6 +74,7 @@ func Test_loadRegion_FromEnv(t *testing.T) { func Test_getHostedZoneID_FromEnv(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() expectedZoneID := "zoneID" @@ -95,8 +84,8 @@ func Test_getHostedZoneID_FromEnv(t *testing.T) { provider, err := NewDNSProvider() require.NoError(t, err) - hostedZoneID, err := provider.getHostedZoneID(context.Background(), "whatever") - require.NoError(t, err, "HostedZoneID") + hostedZoneID, err := provider.getHostedZoneID(t.Context(), "whatever") + require.NoError(t, err) assert.Equal(t, expectedZoneID, hostedZoneID) } @@ -142,6 +131,7 @@ func TestNewDefaultConfig(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { envTest.ClearEnv() + for key, value := range test.envVars { _ = os.Setenv(key, value) } @@ -154,27 +144,50 @@ func TestNewDefaultConfig(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - mockResponses := MockResponseMap{ - "/2013-04-01/hostedzonesbyname": {StatusCode: 200, Body: ListHostedZonesByNameResponse}, - "/2013-04-01/hostedzone/ABCDEFG/rrset": {StatusCode: 200, Body: ChangeResourceRecordSetsResponse}, - "/2013-04-01/change/123456": {StatusCode: 200, Body: GetChangeResponse}, - "/2013-04-01/hostedzone/ABCDEFG/rrset?name=_acme-challenge.example.com.&type=TXT": { - StatusCode: 200, - Body: "", - }, - } - - serverURL := setupTest(t, mockResponses) - defer envTest.RestoreEnv() + envTest.ClearEnv() - provider := makeTestProvider(t, serverURL) + + provider := servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + cfg := aws.Config{ + HTTPClient: server.Client(), + Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "), + Region: "mock-region", + BaseEndpoint: aws.String(server.URL), + RetryMaxAttempts: 1, + } + + return &DNSProvider{ + client: route53.NewFromConfig(cfg), + config: NewDefaultConfig(), + }, nil + }, + ). + Route("GET /2013-04-01/hostedzonesbyname", + servermock.ResponseFromFixture("listHostedZonesByNameResponse.xml"). + WithHeader("Content-Type", "application/xml"), + servermock.CheckQueryParameter().Strict(). + With("dnsname", "example.com")). + Route("POST /2013-04-01/hostedzone/ABCDEFG/rrset", + servermock.ResponseFromFixture("changeResourceRecordSetsResponse.xml"). + WithHeader("Content-Type", "application/xml")). + Route("GET /2013-04-01/change/123456", + servermock.ResponseFromFixture("getChangeResponse.xml"). + WithHeader("Content-Type", "application/xml")). + Route("GET /2013-04-01/hostedzone/ABCDEFG/rrset", + servermock.Noop(). + WithHeader("Content-Type", "application/xml"), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge.example.com."). + With("type", "TXT")). + Build(t) domain := "example.com" keyAuth := "123456d==" err := provider.Present(domain, "", keyAuth) - require.NoError(t, err, "Expected Present to return no error") + require.NoError(t, err) } func Test_createAWSConfig(t *testing.T) { @@ -263,11 +276,12 @@ func Test_createAWSConfig(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.env) - ctx := context.Background() + ctx := t.Context() cfg, err := createAWSConfig(ctx, test.config) requireErr(t, err, test.wantErr) diff --git a/providers/dns/safedns/internal/client.go b/providers/dns/safedns/internal/client.go index 3e6f99919..628618032 100644 --- a/providers/dns/safedns/internal/client.go +++ b/providers/dns/safedns/internal/client.go @@ -19,7 +19,7 @@ const defaultBaseURL = "https://api.ukfast.io/safedns/v1" const authorizationHeader = "Authorization" -// Client the UKFast SafeDNS client. +// Client the ANS SafeDNS client. type Client struct { authToken string @@ -48,6 +48,7 @@ func (c *Client) AddRecord(ctx context.Context, zone string, record Record) (*Ad } respData := &AddRecordResponse{} + err = c.do(req, respData) if err != nil { return nil, fmt.Errorf("add record: %w", err) @@ -132,6 +133,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError + err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/safedns/internal/client_test.go b/providers/dns/safedns/internal/client_test.go index 6709277cd..161a9f078 100644 --- a/providers/dns/safedns/internal/client_test.go +++ b/providers/dns/safedns/internal/client_test.go @@ -1,75 +1,37 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "strings" "testing" "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("secret") - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestClient_AddRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/zones/example.com/records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - if req.Header.Get(authorizationHeader) != "secret" { - http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized) - return - } - - reqBody, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - expectedReqBody := `{"name":"_acme-challenge.example.com","type":"TXT","content":"\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\"","ttl":120}` - if strings.TrimSpace(string(reqBody)) != expectedReqBody { - http.Error(rw, `{"message":"invalid request"}`, http.StatusBadRequest) - return - } - - resp := `{ - "data": { - "id": 1234567 - }, - "meta": { - "location": "https://api.ukfast.io/safedns/v1/zones/example.com/records/1234567" - } - }` - - rw.WriteHeader(http.StatusCreated) - _, err = fmt.Fprint(rw, resp) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /zones/example.com/records", + servermock.ResponseFromFixture("add_record.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromFixture("add_record-request.json")). + Build(t) record := Record{ Name: "_acme-challenge.example.com", @@ -78,7 +40,7 @@ func TestClient_AddRecord(t *testing.T) { TTL: dns01.DefaultTTL, } - response, err := client.AddRecord(context.Background(), "example.com", record) + response, err := client.AddRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &AddRecordResponse{ @@ -97,23 +59,42 @@ func TestClient_AddRecord(t *testing.T) { assert.Equal(t, expected, response) } +func TestClient_AddRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /zones/example.com/records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + record := Record{ + Name: "_acme-challenge.example.com", + Type: "TXT", + Content: `"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI"`, + TTL: dns01.DefaultTTL, + } + + _, err := client.AddRecord(t.Context(), "example.com", record) + require.EqualError(t, err, "add record: [status code: 401] Unauthenticated") +} + func TestClient_RemoveRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /zones/example.com/records/1234567", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) - mux.HandleFunc("/zones/example.com/records/1234567", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - if req.Header.Get(authorizationHeader) != "secret" { - http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized) - return - } - - rw.WriteHeader(http.StatusNoContent) - }) - - err := client.RemoveRecord(context.Background(), "example.com", 1234567) + err := client.RemoveRecord(t.Context(), "example.com", 1234567) require.NoError(t, err) } + +func TestClient_RemoveRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /zones/example.com/records/1234567", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + err := client.RemoveRecord(t.Context(), "example.com", 1234567) + require.EqualError(t, err, "remove record: [status code: 401] Unauthenticated") +} diff --git a/providers/dns/safedns/internal/fixtures/add_record-request.json b/providers/dns/safedns/internal/fixtures/add_record-request.json new file mode 100644 index 000000000..71c8813f2 --- /dev/null +++ b/providers/dns/safedns/internal/fixtures/add_record-request.json @@ -0,0 +1,6 @@ +{ + "name": "_acme-challenge.example.com", + "type": "TXT", + "content": "\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\"", + "ttl": 120 +} diff --git a/providers/dns/safedns/internal/fixtures/add_record.json b/providers/dns/safedns/internal/fixtures/add_record.json new file mode 100644 index 000000000..f3c4ad883 --- /dev/null +++ b/providers/dns/safedns/internal/fixtures/add_record.json @@ -0,0 +1,8 @@ +{ + "data": { + "id": 1234567 + }, + "meta": { + "location": "https://api.ukfast.io/safedns/v1/zones/example.com/records/1234567" + } +} diff --git a/providers/dns/safedns/internal/fixtures/error.json b/providers/dns/safedns/internal/fixtures/error.json new file mode 100644 index 000000000..47fb5916c --- /dev/null +++ b/providers/dns/safedns/internal/fixtures/error.json @@ -0,0 +1,3 @@ +{ + "message": "Unauthenticated" +} diff --git a/providers/dns/safedns/safedns.go b/providers/dns/safedns/safedns.go index cbf217029..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 ( @@ -9,9 +9,12 @@ import ( "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/safedns/internal" + "github.com/miekg/dns" ) // Environment variables. @@ -26,6 +29,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { AuthToken string @@ -70,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") @@ -86,6 +91,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -103,7 +110,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - zone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN)) + zone, err := dns01.FindZoneByFqdn(dns.Fqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("safedns: could not find zone for domain %q: %w", domain, err) } @@ -139,6 +146,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("safedns: unknown record ID for '%s'", info.EffectiveFQDN) } diff --git a/providers/dns/safedns/safedns.toml b/providers/dns/safedns/safedns.toml index 11b2a289c..f387f2535 100644 --- a/providers/dns/safedns/safedns.toml +++ b/providers/dns/safedns/safedns.toml @@ -1,22 +1,22 @@ -Name = "UKFast SafeDNS" +Name = "ANS SafeDNS" Description = '''''' -URL = "https://www.ukfast.co.uk/dns-hosting.html" +URL = "https://www.ans.co.uk/" Code = "safedns" Since = "v4.6.0" Example = ''' SAFEDNS_AUTH_TOKEN=xxxxxx \ -lego --email you@example.com --dns safedns -d '*.example.com' -d example.com run +lego --dns safedns -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] SAFEDNS_AUTH_TOKEN = "Authentication token" [Configuration.Additional] - SAFEDNS_POLLING_INTERVAL = "Time between DNS propagation check" - SAFEDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - SAFEDNS_TTL = "The TTL of the TXT record used for the DNS challenge" - SAFEDNS_HTTP_TIMEOUT = "API request timeout" + SAFEDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + SAFEDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + SAFEDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + SAFEDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.ukfast.io/documentation/safedns" diff --git a/providers/dns/safedns/safedns_test.go b/providers/dns/safedns/safedns_test.go index dcb374718..ce7568056 100644 --- a/providers/dns/safedns/safedns_test.go +++ b/providers/dns/safedns/safedns_test.go @@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -95,6 +96,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -108,6 +110,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/sakuracloud/sakuracloud.go b/providers/dns/sakuracloud/sakuracloud.go index 0b9199684..1adbe3a88 100644 --- a/providers/dns/sakuracloud/sakuracloud.go +++ b/providers/dns/sakuracloud/sakuracloud.go @@ -2,16 +2,21 @@ package sakuracloud import ( + "context" "errors" "fmt" "net/http" + "strings" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" client "github.com/sacloud/api-client-go" "github.com/sacloud/iaas-api-go" + "github.com/sacloud/iaas-api-go/defaults" "github.com/sacloud/iaas-api-go/helper/api" ) @@ -28,6 +33,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Token string @@ -95,13 +102,13 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { Options: &client.Options{ AccessToken: config.Token, AccessTokenSecret: config.Secret, - HttpClient: config.HTTPClient, + HttpClient: clientdebug.Wrap(config.HTTPClient), UserAgent: fmt.Sprintf("%s %s", iaas.DefaultUserAgent, useragent.Get()), }, } return &DNSProvider{ - client: iaas.NewDNSOp(api.NewCallerWithOptions(api.MergeOptions(defaultOption, options))), + client: iaas.NewDNSOp(newCallerWithOptions(api.MergeOptions(defaultOption, options))), config: config, }, nil } @@ -110,7 +117,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - err := d.addTXTRecord(info.EffectiveFQDN, info.Value, d.config.TTL) + err := d.addTXTRecord(context.Background(), info.EffectiveFQDN, info.Value, d.config.TTL) if err != nil { return fmt.Errorf("sakuracloud: %w", err) } @@ -122,7 +129,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - err := d.cleanupTXTRecord(info.EffectiveFQDN, info.Value) + err := d.cleanupTXTRecord(context.Background(), info.EffectiveFQDN, info.Value) if err != nil { return fmt.Errorf("sakuracloud: %w", err) } @@ -135,3 +142,38 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } + +// Extracted from https://github.com/sacloud/iaas-api-go/blob/af06b3ccc2c38625d2dc684ad39590d0ae13eed3/helper/api/caller.go#L36-L81 +// Trace and fake are removed. +// Related to https://github.com/sacloud/iaas-api-go/issues/376. +func newCallerWithOptions(opts *api.CallerOptions) iaas.APICaller { + return newCaller(opts) +} + +func newCaller(opts *api.CallerOptions) iaas.APICaller { + if opts.UserAgent == "" { + opts.UserAgent = iaas.DefaultUserAgent + } + + caller := iaas.NewClientWithOptions(opts.Options) + + defaults.DefaultStatePollingTimeout = 72 * time.Hour + + if opts.DefaultZone != "" { + iaas.APIDefaultZone = opts.DefaultZone + } + + if len(opts.Zones) > 0 { + iaas.SakuraCloudZones = opts.Zones + } + + if opts.APIRootURL != "" { + if strings.HasSuffix(opts.APIRootURL, "/") { + opts.APIRootURL = strings.TrimRight(opts.APIRootURL, "/") + } + + iaas.SakuraCloudAPIRoot = opts.APIRootURL + } + + return caller +} diff --git a/providers/dns/sakuracloud/sakuracloud.toml b/providers/dns/sakuracloud/sakuracloud.toml index f86f215e5..a197cd27c 100644 --- a/providers/dns/sakuracloud/sakuracloud.toml +++ b/providers/dns/sakuracloud/sakuracloud.toml @@ -7,7 +7,7 @@ Since = "v1.1.0" Example = ''' SAKURACLOUD_ACCESS_TOKEN=xxxxx \ SAKURACLOUD_ACCESS_TOKEN_SECRET=yyyyy \ -lego --email you@example.com --dns sakuracloud -d '*.example.com' -d example.com run +lego --dns sakuracloud -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,10 +15,10 @@ lego --email you@example.com --dns sakuracloud -d '*.example.com' -d example.com SAKURACLOUD_ACCESS_TOKEN = "Access token" SAKURACLOUD_ACCESS_TOKEN_SECRET = "Access token secret" [Configuration.Additional] - SAKURACLOUD_POLLING_INTERVAL = "Time between DNS propagation check" - SAKURACLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - SAKURACLOUD_TTL = "The TTL of the TXT record used for the DNS challenge" - SAKURACLOUD_HTTP_TIMEOUT = "API request timeout" + SAKURACLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + SAKURACLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + SAKURACLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + SAKURACLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://developer.sakura.ad.jp/cloud/api/1.1/" diff --git a/providers/dns/sakuracloud/sakuracloud_test.go b/providers/dns/sakuracloud/sakuracloud_test.go index 93cf20ea1..789a27544 100644 --- a/providers/dns/sakuracloud/sakuracloud_test.go +++ b/providers/dns/sakuracloud/sakuracloud_test.go @@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -129,6 +130,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -142,6 +144,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/sakuracloud/wrapper.go b/providers/dns/sakuracloud/wrapper.go index a74478f6c..ff0b78e09 100644 --- a/providers/dns/sakuracloud/wrapper.go +++ b/providers/dns/sakuracloud/wrapper.go @@ -14,11 +14,11 @@ import ( // see: https://github.com/go-acme/lego/pull/850 var mu sync.Mutex -func (d *DNSProvider) addTXTRecord(fqdn, value string, ttl int) error { +func (d *DNSProvider) addTXTRecord(ctx context.Context, fqdn, value string, ttl int) error { mu.Lock() defer mu.Unlock() - zone, err := d.getHostedZone(fqdn) + zone, err := d.getHostedZone(ctx, fqdn) if err != nil { return err } @@ -35,7 +35,7 @@ func (d *DNSProvider) addTXTRecord(fqdn, value string, ttl int) error { TTL: ttl, }) - _, err = d.client.UpdateSettings(context.Background(), zone.ID, &iaas.DNSUpdateSettingsRequest{ + _, err = d.client.UpdateSettings(ctx, zone.ID, &iaas.DNSUpdateSettingsRequest{ Records: records, SettingsHash: zone.SettingsHash, }) @@ -46,11 +46,11 @@ func (d *DNSProvider) addTXTRecord(fqdn, value string, ttl int) error { return nil } -func (d *DNSProvider) cleanupTXTRecord(fqdn, value string) error { +func (d *DNSProvider) cleanupTXTRecord(ctx context.Context, fqdn, value string) error { mu.Lock() defer mu.Unlock() - zone, err := d.getHostedZone(fqdn) + zone, err := d.getHostedZone(ctx, fqdn) if err != nil { return err } @@ -61,8 +61,9 @@ func (d *DNSProvider) cleanupTXTRecord(fqdn, value string) error { } var updRecords iaas.DNSRecords + for _, r := range zone.Records { - if !(r.Name == subDomain && r.Type == "TXT" && r.RData == value) { + if !(r.Name == subDomain && r.Type == "TXT" && r.RData == value) { //nolint:staticcheck // Clearer without De Morgan's law. updRecords = append(updRecords, r) } } @@ -71,7 +72,8 @@ func (d *DNSProvider) cleanupTXTRecord(fqdn, value string) error { Records: updRecords, SettingsHash: zone.SettingsHash, } - _, err = d.client.UpdateSettings(context.Background(), zone.ID, settings) + + _, err = d.client.UpdateSettings(ctx, zone.ID, settings) if err != nil { return fmt.Errorf("API call failed: %w", err) } @@ -79,7 +81,7 @@ func (d *DNSProvider) cleanupTXTRecord(fqdn, value string) error { return nil } -func (d *DNSProvider) getHostedZone(domain string) (*iaas.DNS, error) { +func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (*iaas.DNS, error) { authZone, err := dns01.FindZoneByFqdn(domain) if err != nil { return nil, fmt.Errorf("could not find zone: %w", err) @@ -93,7 +95,7 @@ func (d *DNSProvider) getHostedZone(domain string) (*iaas.DNS, error) { }, } - res, err := d.client.Find(context.Background(), conditions) + res, err := d.client.Find(ctx, conditions) if err != nil { if iaas.IsNotFoundError(err) { return nil, fmt.Errorf("zone %s not found on SakuraCloud DNS: %w", zoneName, err) diff --git a/providers/dns/sakuracloud/wrapper_test.go b/providers/dns/sakuracloud/wrapper_test.go index 91cd3ce0a..7432c67a6 100644 --- a/providers/dns/sakuracloud/wrapper_test.go +++ b/providers/dns/sakuracloud/wrapper_test.go @@ -1,7 +1,6 @@ package sakuracloud import ( - "context" "fmt" "sync" "testing" @@ -33,7 +32,7 @@ func fakeCaller() iaas.APICaller { func createDummyZone(t *testing.T, caller iaas.APICaller) { t.Helper() - ctx := context.Background() + ctx := t.Context() dnsOp := iaas.NewDNSOp(caller) @@ -45,12 +44,13 @@ func createDummyZone(t *testing.T, caller iaas.APICaller) { if zone.Name == "example.com" { err = dnsOp.Delete(ctx, zone.ID) require.NoError(t, err) + break } } // create dummy zone - _, err = iaas.NewDNSOp(caller).Create(context.Background(), &iaas.DNSCreateRequest{Name: "example.com"}) + _, err = iaas.NewDNSOp(caller).Create(t.Context(), &iaas.DNSCreateRequest{Name: "example.com"}) require.NoError(t, err) } @@ -65,10 +65,12 @@ func TestDNSProvider_addAndCleanupRecords(t *testing.T) { require.NoError(t, err) t.Run("addTXTRecord", func(t *testing.T) { - err = p.addTXTRecord("test.example.com.", "dummyValue", 10) + ctx := t.Context() + + err = p.addTXTRecord(ctx, "test.example.com.", "dummyValue", 10) require.NoError(t, err) - updZone, e := p.getHostedZone("test.example.com.") + updZone, e := p.getHostedZone(ctx, "test.example.com.") require.NoError(t, e) require.NotNil(t, updZone) @@ -76,10 +78,12 @@ func TestDNSProvider_addAndCleanupRecords(t *testing.T) { }) t.Run("cleanupTXTRecord", func(t *testing.T) { - err = p.cleanupTXTRecord("test.example.com.", "dummyValue") + ctx := t.Context() + + err = p.cleanupTXTRecord(ctx, "test.example.com.", "dummyValue") require.NoError(t, err) - updZone, e := p.getHostedZone("test.example.com.") + updZone, e := p.getHostedZone(ctx, "test.example.com.") require.NoError(t, e) require.NotNil(t, updZone) @@ -93,6 +97,7 @@ func TestDNSProvider_concurrentAddAndCleanupRecords(t *testing.T) { dummyRecordCount := 10 var providers []*DNSProvider + for range dummyRecordCount { config := NewDefaultConfig() config.Token = "token3" @@ -109,9 +114,11 @@ func TestDNSProvider_concurrentAddAndCleanupRecords(t *testing.T) { t.Run("addTXTRecord", func(t *testing.T) { wg.Add(len(providers)) + ctx := t.Context() + for i, p := range providers { go func(j int, client *DNSProvider) { - err := client.addTXTRecord(fmt.Sprintf("test%d.example.com.", j), "dummyValue", 10) + err := client.addTXTRecord(ctx, fmt.Sprintf("test%d.example.com.", j), "dummyValue", 10) require.NoError(t, err) wg.Done() }(i, p) @@ -119,7 +126,7 @@ func TestDNSProvider_concurrentAddAndCleanupRecords(t *testing.T) { wg.Wait() - updZone, err := providers[0].getHostedZone("example.com.") + updZone, err := providers[0].getHostedZone(ctx, "example.com.") require.NoError(t, err) require.NotNil(t, updZone) @@ -129,9 +136,11 @@ func TestDNSProvider_concurrentAddAndCleanupRecords(t *testing.T) { t.Run("cleanupTXTRecord", func(t *testing.T) { wg.Add(len(providers)) + ctx := t.Context() + for i, p := range providers { go func(i int, client *DNSProvider) { - err := client.cleanupTXTRecord(fmt.Sprintf("test%d.example.com.", i), "dummyValue") + err := client.cleanupTXTRecord(ctx, fmt.Sprintf("test%d.example.com.", i), "dummyValue") require.NoError(t, err) wg.Done() }(i, p) @@ -139,7 +148,7 @@ func TestDNSProvider_concurrentAddAndCleanupRecords(t *testing.T) { wg.Wait() - updZone, err := providers[0].getHostedZone("example.com.") + updZone, err := providers[0].getHostedZone(ctx, "example.com.") require.NoError(t, err) require.NotNil(t, updZone) diff --git a/providers/dns/scaleway/scaleway.go b/providers/dns/scaleway/scaleway.go index d3d027d93..9d08f93b9 100644 --- a/providers/dns/scaleway/scaleway.go +++ b/providers/dns/scaleway/scaleway.go @@ -5,26 +5,20 @@ package scaleway import ( "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/internal/useragent" scwdomain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1" "github.com/scaleway/scaleway-sdk-go/scw" ) -const ( - minTTL = 60 - defaultPollingInterval = 10 * time.Second - defaultPropagationTimeout = 120 * time.Second -) - -// The access key is not used by the Scaleway client. -const dumpAccessKey = "SCWXXXXXXXXXXXXXXXXX" - // Environment variables names. const ( envNamespace = "SCALEWAY_" @@ -40,16 +34,30 @@ const ( EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const ( + minTTL = 60 + defaultPollingInterval = 10 * time.Second + defaultPropagationTimeout = 120 * time.Second +) + +// The access key is not used by the Scaleway client. +const dumpAccessKey = "SCWXXXXXXXXXXXXXXXXX" + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { - ProjectID string - Token string // TODO(ldez) rename to SecretKey in the next major. - AccessKey string + ProjectID string + Token string // TODO(ldez) rename to SecretKey in the next major. + AccessKey string + PropagationTimeout time.Duration PollingInterval time.Duration TTL int + HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. @@ -59,6 +67,9 @@ func NewDefaultConfig() *Config { TTL: env.GetOneWithFallback(EnvTTL, minTTL, strconv.Atoi, altEnvName(EnvTTL)), PropagationTimeout: env.GetOneWithFallback(EnvPropagationTimeout, defaultPropagationTimeout, env.ParseSecond, altEnvName(EnvPropagationTimeout)), PollingInterval: env.GetOneWithFallback(EnvPollingInterval, defaultPollingInterval, env.ParseSecond, altEnvName(EnvPollingInterval)), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, } } @@ -104,6 +115,10 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { scw.WithUserAgent(useragent.Get()), } + if config.HTTPClient != nil { + configuration = append(configuration, scw.WithHTTPClient(clientdebug.Wrap(config.HTTPClient))) + } + if config.ProjectID != "" { configuration = append(configuration, scw.WithDefaultProjectID(config.ProjectID)) } diff --git a/providers/dns/scaleway/scaleway.toml b/providers/dns/scaleway/scaleway.toml index a13a34d22..8b556e8b1 100644 --- a/providers/dns/scaleway/scaleway.toml +++ b/providers/dns/scaleway/scaleway.toml @@ -6,7 +6,7 @@ Since = "v3.4.0" Example = ''' SCW_SECRET_KEY=xxxxxxx-xxxxx-xxxx-xxx-xxxxxx \ -lego --email you@example.com --dns scaleway -d '*.example.com' -d example.com run +lego --dns scaleway -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,9 +15,10 @@ lego --email you@example.com --dns scaleway -d '*.example.com' -d example.com ru SCW_PROJECT_ID = "Project to use (optional)" [Configuration.Additional] SCW_ACCESS_KEY = "Access key" - SCW_POLLING_INTERVAL = "Time between DNS propagation check" - SCW_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - SCW_TTL = "The TTL of the TXT record used for the DNS challenge" + SCW_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + SCW_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + SCW_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + SCW_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.scaleway.com/en/products/domain/dns/api/" diff --git a/providers/dns/scaleway/scaleway_test.go b/providers/dns/scaleway/scaleway_test.go index bf950e84e..b683d751a 100644 --- a/providers/dns/scaleway/scaleway_test.go +++ b/providers/dns/scaleway/scaleway_test.go @@ -41,6 +41,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -105,6 +106,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -118,6 +120,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/selectel/selectel.go b/providers/dns/selectel/selectel.go index 933115c7f..63ddd81ac 100644 --- a/providers/dns/selectel/selectel.go +++ b/providers/dns/selectel/selectel.go @@ -4,20 +4,17 @@ 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/selectel" ) -const minTTL = 60 - // Environment variables names. const ( envNamespace = "SELECTEL_" @@ -31,23 +28,18 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. -type Config struct { - BaseURL string - Token string - PropagationTimeout time.Duration - PollingInterval time.Duration - TTL int - HTTPClient *http.Client -} +type Config = selectel.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - BaseURL: env.GetOrDefaultString(EnvBaseURL, selectel.DefaultSelectelBaseURL), - TTL: env.GetOrDefaultInt(EnvTTL, minTTL), + BaseURL: env.GetOrDefaultString(EnvBaseURL, ""), + TTL: env.GetOrDefaultInt(EnvTTL, selectel.MinTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, @@ -56,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. @@ -80,53 +71,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("selectel: the configuration of the DNS provider is nil") } - if config.Token == "" { - return nil, errors.New("selectel: credentials missing") - } - - if config.TTL < minTTL { - return nil, fmt.Errorf("selectel: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) - } - - client := selectel.NewClient(config.Token) - if config.HTTPClient != nil { - client.HTTPClient = config.HTTPClient - } - - var err error - client.BaseURL, err = url.Parse(config.BaseURL) + provider, err := selectel.NewDNSProviderConfig(config) if err != nil { return nil, fmt.Errorf("selectel: %w", err) } - return &DNSProvider{config: config, client: client}, nil + return &DNSProvider{prv: provider}, nil } -// Timeout returns the Timeout and interval to use when checking for DNS propagation. -// Adjusting here to cope with spikes in propagation times. -func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.config.PropagationTimeout, d.config.PollingInterval -} - -// Present creates a TXT record to fulfill DNS-01 challenge. +// Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - ctx := context.Background() - - // TODO(ldez) replace domain by FQDN to follow CNAME. - domainObj, err := d.client.GetDomainByName(ctx, domain) - if err != nil { - return fmt.Errorf("selectel: %w", err) - } - - txtRecord := selectel.Record{ - Type: "TXT", - TTL: d.config.TTL, - Name: info.EffectiveFQDN, - Content: info.Value, - } - _, err = d.client.AddRecord(ctx, domainObj.ID, txtRecord) + err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("selectel: %w", err) } @@ -134,35 +89,18 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return nil } -// CleanUp removes a TXT record used for DNS-01 challenge. +// CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - recordName := dns01.UnFqdn(info.EffectiveFQDN) - - ctx := context.Background() - - // TODO(ldez) replace domain by FQDN to follow CNAME. - domainObj, err := d.client.GetDomainByName(ctx, domain) + err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("selectel: %w", err) } - records, err := d.client.ListRecords(ctx, domainObj.ID) - if err != nil { - return fmt.Errorf("selectel: %w", err) - } - - // Delete records with specific FQDN - var lastErr error - for _, record := range records { - if record.Name == recordName { - err = d.client.DeleteRecord(ctx, domainObj.ID, record.ID) - if err != nil { - lastErr = fmt.Errorf("selectel: %w", err) - } - } - } - - return lastErr + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.prv.Timeout() } diff --git a/providers/dns/selectel/selectel.toml b/providers/dns/selectel/selectel.toml index a37565d4d..087c97b5b 100644 --- a/providers/dns/selectel/selectel.toml +++ b/providers/dns/selectel/selectel.toml @@ -6,7 +6,7 @@ Since = "v1.2.0" Example = ''' SELECTEL_API_TOKEN=xxxxx \ -lego --email you@example.com --dns selectel -d '*.example.com' -d example.com run +lego --dns selectel -d '*.example.com' -d example.com run ''' [Configuration] @@ -14,10 +14,10 @@ lego --email you@example.com --dns selectel -d '*.example.com' -d example.com ru SELECTEL_API_TOKEN = "API token" [Configuration.Additional] SELECTEL_BASE_URL = "API endpoint URL" - SELECTEL_POLLING_INTERVAL = "Time between DNS propagation check" - SELECTEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - SELECTEL_TTL = "The TTL of the TXT record used for the DNS challenge" - SELECTEL_HTTP_TIMEOUT = "API request timeout" + SELECTEL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + SELECTEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + SELECTEL_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + SELECTEL_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://kb.selectel.com/23136054.html" diff --git a/providers/dns/selectel/selectel_test.go b/providers/dns/selectel/selectel_test.go index 0e2de2dbe..a456f1358 100644 --- a/providers/dns/selectel/selectel_test.go +++ b/providers/dns/selectel/selectel_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/providers/dns/internal/selectel" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -36,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -45,8 +47,7 @@ func TestNewDNSProvider(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - assert.NotNil(t, p.config) - assert.NotNil(t, p.client) + assert.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -76,7 +77,7 @@ func TestNewDNSProviderConfig(t *testing.T) { desc: "bad TTL value", token: "123", ttl: 59, - expected: fmt.Sprintf("selectel: invalid TTL, TTL (59) must be greater than %d", minTTL), + expected: fmt.Sprintf("selectel: invalid TTL, TTL (59) must be greater than %d", selectel.MinTTL), }, } @@ -91,8 +92,7 @@ func TestNewDNSProviderConfig(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - assert.NotNil(t, p.config) - assert.NotNil(t, p.client) + assert.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -106,6 +106,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -119,6 +120,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/selectelv2/selectelv2.go b/providers/dns/selectelv2/selectelv2.go index f5bd10c92..1fcb48583 100644 --- a/providers/dns/selectelv2/selectelv2.go +++ b/providers/dns/selectelv2/selectelv2.go @@ -11,30 +11,25 @@ import ( "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" + "github.com/miekg/dns" selectelapi "github.com/selectel/domains-go/pkg/v2" - "github.com/selectel/go-selvpcclient/v3/selvpcclient" + "github.com/selectel/go-selvpcclient/v4/selvpcclient" "golang.org/x/net/idna" ) -const tokenHeader = "X-Auth-Token" - -const ( - defaultBaseURL = "https://api.selectel.ru/domains/v2" - defaultTTL = 60 - defaultPropagationTimeout = 120 * time.Second - defaultPollingInterval = 5 * time.Second - defaultHTTPTimeout = 30 * time.Second -) - const ( envNamespace = "SELECTELV2_" - EnvBaseURL = envNamespace + "BASE_URL" - EnvUsernameOS = envNamespace + "USERNAME" - EnvPasswordOS = envNamespace + "PASSWORD" - EnvAccount = envNamespace + "ACCOUNT_ID" - EnvProjectID = envNamespace + "PROJECT_ID" + EnvBaseURL = envNamespace + "BASE_URL" + EnvUsernameOS = envNamespace + "USERNAME" + EnvPasswordOS = envNamespace + "PASSWORD" + EnvDomainName = envNamespace + "ACCOUNT_ID" + EnvProjectID = envNamespace + "PROJECT_ID" + EnvAuthRegion = envNamespace + "AUTH_REGION" + EnvAuthURL = envNamespace + "AUTH_URL" + EnvUserDomainName = envNamespace + "USER_DOMAIN_NAME" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" @@ -42,15 +37,34 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const ( + defaultBaseURL = "https://api.selectel.ru/domains/v2" + defaultAuthRegion = "ru-1" + defaultAuthURL = "https://cloud.api.selcloud.ru/identity/v3/" +) + +const ( + defaultTTL = 60 + defaultPropagationTimeout = 120 * time.Second + defaultPollingInterval = 5 * time.Second + defaultHTTPTimeout = 30 * time.Second +) + +const tokenHeader = "X-Auth-Token" + var errNotFound = errors.New("rrset not found") // Config is used to configure the creation of the DNSProvider. type Config struct { - BaseURL string - Username string - Password string - Account string - ProjectID string + BaseURL string + Username string + Password string + DomainName string + ProjectID string + AuthURL string + AuthRegion string + UserDomainName string + TTL int PropagationTimeout time.Duration PollingInterval time.Duration @@ -60,7 +74,10 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - BaseURL: env.GetOrDefaultString(EnvBaseURL, defaultBaseURL), + BaseURL: env.GetOrDefaultString(EnvBaseURL, defaultBaseURL), + AuthRegion: env.GetOrDefaultString(EnvAuthRegion, defaultAuthRegion), + AuthURL: env.GetOrDefaultString(EnvAuthURL, defaultAuthURL), + TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, defaultPollingInterval), @@ -77,7 +94,7 @@ type DNSProvider struct { // NewDNSProvider returns a DNSProvider instance configured for Selectel Domains APIv2. func NewDNSProvider() (*DNSProvider, error) { - values, err := env.Get(EnvUsernameOS, EnvPasswordOS, EnvAccount, EnvProjectID) + values, err := env.Get(EnvUsernameOS, EnvPasswordOS, EnvDomainName, EnvProjectID) if err != nil { return nil, fmt.Errorf("selectelv2: %w", err) } @@ -85,8 +102,9 @@ func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() config.Username = values[EnvUsernameOS] config.Password = values[EnvPasswordOS] - config.Account = values[EnvAccount] + config.DomainName = values[EnvDomainName] config.ProjectID = values[EnvProjectID] + config.UserDomainName = env.GetOrDefaultString(EnvUserDomainName, "") return NewDNSProviderConfig(config) } @@ -105,8 +123,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("selectelv2: missing password") } - if config.Account == "" { - return nil, errors.New("selectelv2: missing account") + if config.DomainName == "" { + return nil, errors.New("selectelv2: missing account ID") } if config.ProjectID == "" { @@ -117,22 +135,22 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { useragent.SetHeader(headers) return &DNSProvider{ - baseClient: selectelapi.NewClient(config.BaseURL, config.HTTPClient, headers), + baseClient: selectelapi.NewClient(config.BaseURL, clientdebug.Wrap(config.HTTPClient), headers), config: config, }, nil } // Timeout returns the Timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. -func (p *DNSProvider) Timeout() (timeout, interval time.Duration) { - return p.config.PropagationTimeout, p.config.PollingInterval +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill DNS-01 challenge. -func (p *DNSProvider) Present(domain, _, keyAuth string) error { +func (d *DNSProvider) Present(domain, _, keyAuth string) error { ctx := context.Background() - client, err := p.authorize() + client, err := d.authorize(ctx) if err != nil { return fmt.Errorf("selectelv2: authorize: %w", err) } @@ -153,7 +171,7 @@ func (p *DNSProvider) Present(domain, _, keyAuth string) error { newRRSet := &selectelapi.RRSet{ Name: info.EffectiveFQDN, Type: selectelapi.TXT, - TTL: p.config.TTL, + TTL: d.config.TTL, Records: []selectelapi.RecordItem{{Content: fmt.Sprintf("%q", info.Value)}}, } @@ -176,10 +194,10 @@ func (p *DNSProvider) Present(domain, _, keyAuth string) error { } // CleanUp removes a TXT record used for DNS-01 challenge. -func (p *DNSProvider) CleanUp(domain, _, keyAuth string) error { +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { ctx := context.Background() - client, err := p.authorize() + client, err := d.authorize(ctx) if err != nil { return fmt.Errorf("selectelv2: authorize: %w", err) } @@ -220,8 +238,8 @@ func (p *DNSProvider) CleanUp(domain, _, keyAuth string) error { return nil } -func (p *DNSProvider) authorize() (*clientWrapper, error) { - token, err := obtainOpenstackToken(p.config) +func (d *DNSProvider) authorize(ctx context.Context) (*clientWrapper, error) { + token, err := obtainOpenstackToken(ctx, d.config) if err != nil { return nil, err } @@ -230,16 +248,20 @@ func (p *DNSProvider) authorize() (*clientWrapper, error) { extraHeaders.Set(tokenHeader, token) return &clientWrapper{ - DNSClient: p.baseClient.WithHeaders(extraHeaders), + DNSClient: d.baseClient.WithHeaders(extraHeaders), }, nil } -func obtainOpenstackToken(config *Config) (string, error) { +func obtainOpenstackToken(ctx context.Context, config *Config) (string, error) { vpcClient, err := selvpcclient.NewClient(&selvpcclient.ClientOptions{ + Context: ctx, + DomainName: config.DomainName, + AuthURL: config.AuthURL, + AuthRegion: config.AuthRegion, Username: config.Username, Password: config.Password, - UserDomainName: config.Account, ProjectID: config.ProjectID, + UserDomainName: config.UserDomainName, }) if err != nil { return "", fmt.Errorf("new VPC client: %w", err) @@ -266,7 +288,7 @@ func (w *clientWrapper) getZone(ctx context.Context, name string) (*selectelapi. } for _, zone := range zones.GetItems() { - if zone.Name == dns01.ToFqdn(unicodeName) { + if zone.Name == dns.Fqdn(unicodeName) { return zone, nil } } @@ -275,10 +297,10 @@ func (w *clientWrapper) getZone(ctx context.Context, name string) (*selectelapi. return nil, fmt.Errorf("zone '%s' for challenge has not been found", name) } - // -1 can not be returned since if no dots present we exit above - i := strings.Index(name, ".") + // after is always defined since if no dots present we exit above. + _, after, _ := strings.Cut(name, ".") - return w.getZone(ctx, name[i+1:]) + return w.getZone(ctx, after) } func (w *clientWrapper) getRRset(ctx context.Context, name, zoneID string) (*selectelapi.RRSet, error) { @@ -295,7 +317,7 @@ func (w *clientWrapper) getRRset(ctx context.Context, name, zoneID string) (*sel } for _, rrset := range resp.GetItems() { - if rrset.Name == dns01.ToFqdn(unicodeName) { + if rrset.Name == dns.Fqdn(unicodeName) { return rrset, nil } } diff --git a/providers/dns/selectelv2/selectelv2.toml b/providers/dns/selectelv2/selectelv2.toml index 4c06949f4..480c7756e 100644 --- a/providers/dns/selectelv2/selectelv2.toml +++ b/providers/dns/selectelv2/selectelv2.toml @@ -9,7 +9,7 @@ SELECTELV2_USERNAME=trex \ SELECTELV2_PASSWORD=xxxxx \ SELECTELV2_ACCOUNT_ID=1234567 \ SELECTELV2_PROJECT_ID=111a11111aaa11aa1a11aaa11111aa1a \ -lego --email you@example.com --dns selectelv2 -d '*.example.com' -d example.com run +lego --dns selectelv2 -d '*.example.com' -d example.com run ''' [Configuration] @@ -20,10 +20,13 @@ lego --email you@example.com --dns selectelv2 -d '*.example.com' -d example.com SELECTELV2_PROJECT_ID = "Cloud project ID (UUID)" [Configuration.Additional] SELECTELV2_BASE_URL = "API endpoint URL" - SELECTELV2_POLLING_INTERVAL = "Time between DNS propagation check" - SELECTELV2_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - SELECTELV2_TTL = "The TTL of the TXT record used for the DNS challenge" - SELECTELV2_HTTP_TIMEOUT = "API request timeout" + SELECTELV2_AUTH_REGION = "Location for auth endpoint like ResellAPI or Keystone (default: 'ru-1')" + SELECTELV2_AUTH_URL = "Identity endpoint (defaul: 'https://cloud.api.selcloud.ru/identity/v3/')" + SELECTELV2_USER_DOMAIN_NAME = "To specify the domain name (account ID) where the user is located. (default: SELECTELV2_ACCOUNT_ID)" + SELECTELV2_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" + SELECTELV2_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + SELECTELV2_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + SELECTELV2_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.selectel.ru/docs/cloud-services/dns_api/dns_api_actual/" diff --git a/providers/dns/selectelv2/selectelv2_test.go b/providers/dns/selectelv2/selectelv2_test.go index 4859b9932..2627fa023 100644 --- a/providers/dns/selectelv2/selectelv2_test.go +++ b/providers/dns/selectelv2/selectelv2_test.go @@ -11,7 +11,15 @@ import ( const envDomain = envNamespace + "DOMAIN" -var envTest = tester.NewEnvTest(EnvUsernameOS, EnvPasswordOS, EnvAccount, EnvProjectID). +var envTest = tester.NewEnvTest( + EnvUsernameOS, + EnvPasswordOS, + EnvDomainName, + EnvUserDomainName, + EnvProjectID, + EnvAuthRegion, + EnvAuthURL, +). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { @@ -25,7 +33,7 @@ func TestNewDNSProvider(t *testing.T) { envVars: map[string]string{ EnvUsernameOS: "someName", EnvPasswordOS: "qwerty", - EnvAccount: "1", + EnvDomainName: "1", EnvProjectID: "111a11111aaa11aa1a11aaa11111aa1a", }, }, @@ -33,7 +41,7 @@ func TestNewDNSProvider(t *testing.T) { desc: "missing username", envVars: map[string]string{ EnvPasswordOS: "qwerty", - EnvAccount: "1", + EnvDomainName: "1", EnvProjectID: "111a11111aaa11aa1a11aaa11111aa1a", }, expected: "selectelv2: some credentials information are missing: SELECTELV2_USERNAME", @@ -42,7 +50,7 @@ func TestNewDNSProvider(t *testing.T) { desc: "missing password", envVars: map[string]string{ EnvUsernameOS: "someName", - EnvAccount: "1", + EnvDomainName: "1", EnvProjectID: "111a11111aaa11aa1a11aaa11111aa1a", }, expected: "selectelv2: some credentials information are missing: SELECTELV2_PASSWORD", @@ -61,7 +69,7 @@ func TestNewDNSProvider(t *testing.T) { envVars: map[string]string{ EnvUsernameOS: "someName", EnvPasswordOS: "qwerty", - EnvAccount: "1", + EnvDomainName: "1", }, expected: "selectelv2: some credentials information are missing: SELECTELV2_PROJECT_ID", }, @@ -70,6 +78,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -123,7 +132,7 @@ func TestNewDNSProviderConfig(t *testing.T) { username: "user", password: "secret", projectID: "111a11111aaa11aa1a11aaa11111aa1a", - expected: "selectelv2: missing account", + expected: "selectelv2: missing account ID", }, { desc: "missing projectID", @@ -139,7 +148,7 @@ func TestNewDNSProviderConfig(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password - config.Account = test.account + config.DomainName = test.account config.ProjectID = test.projectID p, err := NewDNSProviderConfig(config) @@ -162,6 +171,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -175,6 +185,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/selfhostde/internal/client_test.go b/providers/dns/selfhostde/internal/client_test.go index 8abda8fb6..22949728c 100644 --- a/providers/dns/selfhostde/internal/client_test.go +++ b/providers/dns/selfhostde/internal/client_test.go @@ -1,65 +1,41 @@ package internal import ( - "context" - "fmt" "net/http" "net/http/httptest" - "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") - serverURL, err := url.Parse(server.URL) - require.NoError(t, err) + client.baseURL = server.URL + client.HTTPClient = server.Client() - client.baseURL = serverURL.String() - - return client, mux + return client, nil } func TestClient_UpdateTXTRecord(t *testing.T) { - client, mux := setupTest(t) + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", nil, servermock.CheckQueryParameter().Strict(). + With("rid", "123456"). + With("content", "txt"). + With("username", "user"). + With("password", "secret"), + ). + Build(t) - mux.HandleFunc("GET /", func(rw http.ResponseWriter, req *http.Request) { - query := req.URL.Query() - - fields := map[string]string{ - "username": "user", - "password": "secret", - "rid": "123456", - "content": "txt", - } - - for k, v := range fields { - value := query.Get(k) - if value != v { - http.Error(rw, fmt.Sprintf("%s: unexpected value: %s (%s)", k, value, v), http.StatusBadRequest) - return - } - } - }) - - err := client.UpdateTXTRecord(context.Background(), "123456", "txt") + err := client.UpdateTXTRecord(t.Context(), "123456", "txt") require.NoError(t, err) } func TestClient_UpdateTXTRecord_error(t *testing.T) { - client, mux := setupTest(t) + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", servermock.Noop().WithStatusCode(http.StatusBadRequest)). + Build(t) - mux.HandleFunc("GET /", func(rw http.ResponseWriter, _ *http.Request) { - http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - }) - - err := client.UpdateTXTRecord(context.Background(), "123456", "txt") - require.Error(t, err) + err := client.UpdateTXTRecord(t.Context(), "123456", "txt") + require.EqualError(t, err, "unexpected status code: [status code: 400] body: ") } diff --git a/providers/dns/selfhostde/mapping.go b/providers/dns/selfhostde/mapping.go index 0984419ef..fe11ceda1 100644 --- a/providers/dns/selfhostde/mapping.go +++ b/providers/dns/selfhostde/mapping.go @@ -88,8 +88,10 @@ func parseLine(line string) (string, *Seq, error) { name, rawIDs := line[:idx], line[idx+1:] - var ids []string - var count int + var ( + ids []string + count int + ) for { idx, err = safeIndex(rawIDs, recordSep) diff --git a/providers/dns/selfhostde/selfhostde.go b/providers/dns/selfhostde/selfhostde.go index 324287665..035cd5363 100644 --- a/providers/dns/selfhostde/selfhostde.go +++ b/providers/dns/selfhostde/selfhostde.go @@ -10,8 +10,10 @@ import ( "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/selfhostde/internal" ) @@ -29,6 +31,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Username string @@ -129,6 +133,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -170,6 +176,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("selfhostde: unknown record ID for %q", dns01.UnFqdn(info.EffectiveFQDN)) } @@ -179,5 +186,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("selfhostde: emptied DNS TXT record (id=%s): %w", recordID, err) } + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + return nil } diff --git a/providers/dns/selfhostde/selfhostde.toml b/providers/dns/selfhostde/selfhostde.toml index eba96fce2..bd22c6c41 100644 --- a/providers/dns/selfhostde/selfhostde.toml +++ b/providers/dns/selfhostde/selfhostde.toml @@ -8,7 +8,7 @@ Example = ''' SELFHOSTDE_USERNAME=xxx \ SELFHOSTDE_PASSWORD=yyy \ SELFHOSTDE_RECORDS_MAPPING=my.example.com:123 \ -lego --email you@example.com --dns selfhostde -d '*.example.com' -d example.com run +lego --dns selfhostde -d '*.example.com' -d example.com run ''' Additional = """ @@ -48,7 +48,7 @@ The resulting environment variable would then be: `SELFHOSTDE_RECORDS_MAPPING=my SELFHOSTDE_PASSWORD = "Password" SELFHOSTDE_RECORDS_MAPPING = "Record IDs mapping with domains (ex: example.com:123:456,example.org:789,foo.example.com:147)" [Configuration.Additional] - SELFHOSTDE_POLLING_INTERVAL = "Time between DNS propagation check" - SELFHOSTDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - SELFHOSTDE_TTL = "The TTL of the TXT record used for the DNS challenge" - SELFHOSTDE_HTTP_TIMEOUT = "API request timeout" + SELFHOSTDE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)" + SELFHOSTDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 240)" + SELFHOSTDE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + SELFHOSTDE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" diff --git a/providers/dns/selfhostde/selfhostde_test.go b/providers/dns/selfhostde/selfhostde_test.go index 1161049b0..7c12195fa 100644 --- a/providers/dns/selfhostde/selfhostde_test.go +++ b/providers/dns/selfhostde/selfhostde_test.go @@ -71,6 +71,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -185,6 +186,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -198,6 +200,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/servercow/internal/client.go b/providers/dns/servercow/internal/client.go index 3695b0979..e15237201 100644 --- a/providers/dns/servercow/internal/client.go +++ b/providers/dns/servercow/internal/client.go @@ -47,6 +47,7 @@ func (c *Client) GetRecords(ctx context.Context, domain string) ([]Record, error } var records []Record + err = c.do(req, &records) if err != nil { return nil, err @@ -65,6 +66,7 @@ func (c *Client) CreateUpdateRecord(ctx context.Context, domain string, data Rec } var msg Message + err = c.do(req, &msg) if err != nil { return nil, err @@ -87,6 +89,7 @@ func (c *Client) DeleteRecord(ctx context.Context, domain string, data Record) ( } var msg Message + err = c.do(req, &msg) if err != nil { return nil, err @@ -168,6 +171,7 @@ func unmarshal(raw []byte, v any) error { } var apiErr Message + errU := json.Unmarshal(raw, &apiErr) if errU != nil { return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw)) diff --git a/providers/dns/servercow/internal/client_test.go b/providers/dns/servercow/internal/client_test.go index 8597d7e12..3733ccad1 100644 --- a/providers/dns/servercow/internal/client_test.go +++ b/providers/dns/servercow/internal/client_test.go @@ -1,57 +1,38 @@ package internal import ( - "context" "encoding/json" - "io" - "net/http" "net/http/httptest" "net/url" "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("", "") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With("X-Auth-Username", "user"). + With("X-Auth-Password", "secret"), + ) } func TestClient_GetRecords(t *testing.T) { - client, handler := setupTest(t) + client := mockBuilder(). + Route("GET /example.com", servermock.ResponseFromFixture("records-01.json")). + Build(t) - handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/records-01.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - records, err := client.GetRecords(context.Background(), "lego.wtf") + records, err := client.GetRecords(t.Context(), "example.com") require.NoError(t, err) recordsJSON, err := json.Marshal(records) @@ -64,55 +45,22 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecords_error(t *testing.T) { - client, handler := setupTest(t) + client := mockBuilder(). + Route("GET /example.com", servermock.JSONEncode(Message{ErrorMsg: "authentication failed"})). + Build(t) - handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - err := json.NewEncoder(rw).Encode(Message{ErrorMsg: "authentication failed"}) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - records, err := client.GetRecords(context.Background(), "lego.wtf") + records, err := client.GetRecords(t.Context(), "example.com") require.Error(t, err) assert.Nil(t, records) } func TestClient_CreateUpdateRecord(t *testing.T) { - client, handler := setupTest(t) - - handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - content, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - expectedRequest := `{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":["aaa","bbb"]}` - - if !assert.JSONEq(t, expectedRequest, string(content)) { - http.Error(rw, "invalid content", http.StatusBadRequest) - return - } - - err = json.NewEncoder(rw).Encode(Message{Message: "ok"}) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /example.com", + servermock.JSONEncode(Message{Message: "ok"}), + servermock.CheckRequestJSONBody(`{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":["aaa","bbb"]}`)). + Build(t) record := Record{ Name: "_acme-challenge.www", @@ -121,7 +69,7 @@ func TestClient_CreateUpdateRecord(t *testing.T) { Content: Value{"aaa", "bbb"}, } - msg, err := client.CreateUpdateRecord(context.Background(), "lego.wtf", record) + msg, err := client.CreateUpdateRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &Message{Message: "ok"} @@ -129,66 +77,34 @@ func TestClient_CreateUpdateRecord(t *testing.T) { } func TestClient_CreateUpdateRecord_error(t *testing.T) { - client, handler := setupTest(t) - - handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - err := json.NewEncoder(rw).Encode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"}) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /example.com", + servermock.JSONEncode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"})). + Build(t) record := Record{ Name: "_acme-challenge.www", } - msg, err := client.CreateUpdateRecord(context.Background(), "lego.wtf", record) + msg, err := client.CreateUpdateRecord(t.Context(), "example.com", record) require.Error(t, err) assert.Nil(t, msg) } func TestClient_DeleteRecord(t *testing.T) { - client, handler := setupTest(t) - - handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - content, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - expectedRequest := `{"name":"_acme-challenge.www","type":"TXT"}` - - if !assert.JSONEq(t, expectedRequest, string(content)) { - http.Error(rw, "invalid content", http.StatusBadRequest) - return - } - - err = json.NewEncoder(rw).Encode(Message{Message: "ok"}) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("DELETE /example.com", + servermock.JSONEncode(Message{Message: "ok"}), + servermock.CheckRequestJSONBody(`{"name":"_acme-challenge.www","type":"TXT"}`)). + Build(t) record := Record{ Name: "_acme-challenge.www", Type: "TXT", } - msg, err := client.DeleteRecord(context.Background(), "lego.wtf", record) + msg, err := client.DeleteRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &Message{Message: "ok"} @@ -196,26 +112,16 @@ func TestClient_DeleteRecord(t *testing.T) { } func TestClient_DeleteRecord_error(t *testing.T) { - client, handler := setupTest(t) - - handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - err := json.NewEncoder(rw).Encode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"}) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("DELETE /example.com", + servermock.JSONEncode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"})). + Build(t) record := Record{ Name: "_acme-challenge.www", } - msg, err := client.DeleteRecord(context.Background(), "lego.wtf", record) + msg, err := client.DeleteRecord(t.Context(), "example.com", record) require.Error(t, err) assert.Nil(t, msg) diff --git a/providers/dns/servercow/internal/types.go b/providers/dns/servercow/internal/types.go index 5a8fb6ff8..9a951e806 100644 --- a/providers/dns/servercow/internal/types.go +++ b/providers/dns/servercow/internal/types.go @@ -43,6 +43,7 @@ func (v *Value) UnmarshalJSON(b []byte) error { } *v = append(*v, s) + return nil } diff --git a/providers/dns/servercow/servercow.go b/providers/dns/servercow/servercow.go index 3db4ac454..557c6b1ec 100644 --- a/providers/dns/servercow/servercow.go +++ b/providers/dns/servercow/servercow.go @@ -9,8 +9,10 @@ import ( "slices" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/servercow/internal" ) @@ -27,6 +29,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Username string @@ -41,7 +45,7 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - TTL: env.GetOrDefaultInt(EnvTTL, 120), + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ @@ -82,6 +86,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -134,6 +140,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("servercow: failed to update TXT records: %w", err) } + return nil } @@ -188,6 +195,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("servercow: failed to delete TXT records: %w", err) } + return nil } diff --git a/providers/dns/servercow/servercow.toml b/providers/dns/servercow/servercow.toml index e9ec36be9..5cbacbb88 100644 --- a/providers/dns/servercow/servercow.toml +++ b/providers/dns/servercow/servercow.toml @@ -7,7 +7,7 @@ Since = "v3.4.0" Example = ''' SERVERCOW_USERNAME=xxxxxxxx \ SERVERCOW_PASSWORD=xxxxxxxx \ -lego --email you@example.com --dns servercow -d '*.example.com' -d example.com run +lego --dns servercow -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,10 +15,10 @@ lego --email you@example.com --dns servercow -d '*.example.com' -d example.com r SERVERCOW_USERNAME = "API username" SERVERCOW_PASSWORD = "API password" [Configuration.Additional] - SERVERCOW_POLLING_INTERVAL = "Time between DNS propagation check" - SERVERCOW_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - SERVERCOW_TTL = "The TTL of the TXT record used for the DNS challenge" - SERVERCOW_HTTP_TIMEOUT = "API request timeout" + SERVERCOW_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + SERVERCOW_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + SERVERCOW_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + SERVERCOW_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] - API = "https://cp.servercow.de/client/plugin/support_manager/knowledgebase/view/34/dns-api-v1/7/" + API = "https://wiki.servercow.de/en/domains/dns_api/api-syntax/" diff --git a/providers/dns/servercow/servercow_test.go b/providers/dns/servercow/servercow_test.go index 1c3facad9..f2328fe1a 100644 --- a/providers/dns/servercow/servercow_test.go +++ b/providers/dns/servercow/servercow_test.go @@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -129,6 +130,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -142,6 +144,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/shellrent/internal/client.go b/providers/dns/shellrent/internal/client.go index a361ccf1d..a70ff5452 100644 --- a/providers/dns/shellrent/internal/client.go +++ b/providers/dns/shellrent/internal/client.go @@ -29,7 +29,7 @@ type Client struct { } // NewClient Creates a new Client. -func NewClient(username string, token string) *Client { +func NewClient(username, token string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ @@ -42,7 +42,7 @@ func NewClient(username string, token string) *Client { // ListServices lists service IDs. // https://api.shellrent.com/elenco-dei-servizi-acquistati -func (c Client) ListServices(ctx context.Context) ([]int, error) { +func (c *Client) ListServices(ctx context.Context) ([]int, error) { endpoint := c.baseURL.JoinPath("purchase") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -72,7 +72,7 @@ func (c Client) ListServices(ctx context.Context) ([]int, error) { // GetServiceDetails gets service details. // https://api.shellrent.com/dettagli-servizio-acquistato -func (c Client) GetServiceDetails(ctx context.Context, serviceID int) (*ServiceDetails, error) { +func (c *Client) GetServiceDetails(ctx context.Context, serviceID int) (*ServiceDetails, error) { endpoint := c.baseURL.JoinPath("purchase", "details", strconv.Itoa(serviceID)) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -96,7 +96,7 @@ func (c Client) GetServiceDetails(ctx context.Context, serviceID int) (*ServiceD // GetDomainDetails gets domain details. // https://api.shellrent.com/dettagli-dominio -func (c Client) GetDomainDetails(ctx context.Context, domainID int) (*DomainDetails, error) { +func (c *Client) GetDomainDetails(ctx context.Context, domainID int) (*DomainDetails, error) { endpoint := c.baseURL.JoinPath("domain", "details", strconv.Itoa(domainID)) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -114,12 +114,13 @@ func (c Client) GetDomainDetails(ctx context.Context, domainID int) (*DomainDeta if result.Code != 0 { return nil, result.Base } + return result.Data, nil } // CreateRecord created a record. // https://api.shellrent.com/creazione-record-dns-di-un-dominio -func (c Client) CreateRecord(ctx context.Context, domainID int, record Record) (int, error) { +func (c *Client) CreateRecord(ctx context.Context, domainID int, record Record) (int, error) { endpoint := c.baseURL.JoinPath("dns_record", "store", strconv.Itoa(domainID)) req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) @@ -137,12 +138,13 @@ func (c Client) CreateRecord(ctx context.Context, domainID int, record Record) ( if result.Code != 0 { return 0, result.Base } + return result.Data.ID.Value(), nil } // DeleteRecord deletes a record. // https://api.shellrent.com/eliminazione-record-dns-di-un-dominio -func (c Client) DeleteRecord(ctx context.Context, domainID int, recordID int) error { +func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int) error { endpoint := c.baseURL.JoinPath("dns_record", "remove", strconv.Itoa(domainID), strconv.Itoa(recordID)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) @@ -164,7 +166,7 @@ func (c Client) DeleteRecord(ctx context.Context, domainID int, recordID int) er return nil } -func (c Client) do(req *http.Request, result any) error { +func (c *Client) do(req *http.Request, result any) error { req.Header.Set(authorizationHeader, c.username+"."+c.token) resp, err := c.HTTPClient.Do(req) @@ -219,6 +221,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response Base + err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/shellrent/internal/client_test.go b/providers/dns/shellrent/internal/client_test.go index 0fe77c6fc..7047ce835 100644 --- a/providers/dns/shellrent/internal/client_test.go +++ b/providers/dns/shellrent/internal/client_test.go @@ -1,71 +1,35 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "user.secret" { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("user.secret")) } func TestClient_ListServices(t *testing.T) { - client := setupTest(t, http.MethodGet, "/purchase", http.StatusOK, "purchase.json") + client := mockBuilder(). + Route("GET /purchase", servermock.ResponseFromFixture("purchase.json")). + Build(t) - services, err := client.ListServices(context.Background()) + services, err := client.ListServices(t.Context()) require.NoError(t, err) expected := []int{2018, 10039, 10128} @@ -74,23 +38,31 @@ func TestClient_ListServices(t *testing.T) { } func TestClient_ListServices_error(t *testing.T) { - client := setupTest(t, http.MethodGet, "/purchase", http.StatusOK, "error.json") + client := mockBuilder(). + Route("GET /purchase", servermock.ResponseFromFixture("error.json")). + Build(t) - _, err := client.ListServices(context.Background()) + _, err := client.ListServices(t.Context()) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_ListServices_error_status(t *testing.T) { - client := setupTest(t, http.MethodGet, "/purchase", http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("GET /purchase", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - _, err := client.ListServices(context.Background()) + _, err := client.ListServices(t.Context()) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_GetServiceDetails(t *testing.T) { - client := setupTest(t, http.MethodGet, "/purchase/details/123", http.StatusOK, "purchase-details.json") + client := mockBuilder(). + Route("GET /purchase/details/123", servermock.ResponseFromFixture("purchase-details.json")). + Build(t) - services, err := client.GetServiceDetails(context.Background(), 123) + services, err := client.GetServiceDetails(t.Context(), 123) require.NoError(t, err) expected := &ServiceDetails{ID: 123, Name: "example", DomainID: 456} @@ -99,23 +71,31 @@ func TestClient_GetServiceDetails(t *testing.T) { } func TestClient_GetServiceDetails_error(t *testing.T) { - client := setupTest(t, http.MethodGet, "/purchase/details/123", http.StatusOK, "error.json") + client := mockBuilder(). + Route("GET /purchase/details/123", servermock.ResponseFromFixture("error.json")). + Build(t) - _, err := client.GetServiceDetails(context.Background(), 123) + _, err := client.GetServiceDetails(t.Context(), 123) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_GetServiceDetails_error_status(t *testing.T) { - client := setupTest(t, http.MethodGet, "/purchase/details/123", http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("GET /purchase/details/123", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - _, err := client.GetServiceDetails(context.Background(), 123) + _, err := client.GetServiceDetails(t.Context(), 123) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_GetDomainDetails(t *testing.T) { - client := setupTest(t, http.MethodGet, "/domain/details/123", http.StatusOK, "domain-details.json") + client := mockBuilder(). + Route("GET /domain/details/123", servermock.ResponseFromFixture("domain-details.json")). + Build(t) - services, err := client.GetDomainDetails(context.Background(), 123) + services, err := client.GetDomainDetails(t.Context(), 123) require.NoError(t, err) expected := &DomainDetails{ID: 123, DomainName: "example.com", DomainNameASCII: "example.com"} @@ -124,23 +104,31 @@ func TestClient_GetDomainDetails(t *testing.T) { } func TestClient_GetDomainDetails_error(t *testing.T) { - client := setupTest(t, http.MethodGet, "/domain/details/123", http.StatusOK, "error.json") + client := mockBuilder(). + Route("GET /domain/details/123", servermock.ResponseFromFixture("error.json")). + Build(t) - _, err := client.GetDomainDetails(context.Background(), 123) + _, err := client.GetDomainDetails(t.Context(), 123) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_GetDomainDetails_error_status(t *testing.T) { - client := setupTest(t, http.MethodGet, "/domain/details/123", http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("GET /domain/details/123", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - _, err := client.GetDomainDetails(context.Background(), 123) + _, err := client.GetDomainDetails(t.Context(), 123) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_CreateRecord(t *testing.T) { - client := setupTest(t, http.MethodPost, "/dns_record/store/123", http.StatusOK, "dns_record-store.json") + client := mockBuilder(). + Route("POST /dns_record/store/123", servermock.ResponseFromFixture("dns_record-store.json")). + Build(t) - services, err := client.CreateRecord(context.Background(), 123, Record{}) + services, err := client.CreateRecord(t.Context(), 123, Record{}) require.NoError(t, err) expected := 2255674 @@ -149,37 +137,51 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_CreateRecord_error(t *testing.T) { - client := setupTest(t, http.MethodPost, "/dns_record/store/123", http.StatusOK, "error.json") + client := mockBuilder(). + Route("POST /dns_record/store/123", servermock.ResponseFromFixture("error.json")). + Build(t) - _, err := client.CreateRecord(context.Background(), 123, Record{}) + _, err := client.CreateRecord(t.Context(), 123, Record{}) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_CreateRecord_error_status(t *testing.T) { - client := setupTest(t, http.MethodPost, "/dns_record/store/123", http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("POST /dns_record/store/123", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - _, err := client.CreateRecord(context.Background(), 123, Record{}) + _, err := client.CreateRecord(t.Context(), 123, Record{}) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns_record/remove/123/456", http.StatusOK, "dns_record-remove.json") + client := mockBuilder(). + Route("DELETE /dns_record/remove/123/456", servermock.ResponseFromFixture("dns_record-remove.json")). + Build(t) - err := client.DeleteRecord(context.Background(), 123, 456) + err := client.DeleteRecord(t.Context(), 123, 456) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns_record/remove/123/456", http.StatusOK, "error.json") + client := mockBuilder(). + Route("DELETE /dns_record/remove/123/456", servermock.ResponseFromFixture("error.json")). + Build(t) - err := client.DeleteRecord(context.Background(), 123, 456) + err := client.DeleteRecord(t.Context(), 123, 456) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_DeleteRecord_error_status(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns_record/remove/123/456", http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("DELETE /dns_record/remove/123/456", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - err := client.DeleteRecord(context.Background(), 123, 456) + err := client.DeleteRecord(t.Context(), 123, 456) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } diff --git a/providers/dns/shellrent/internal/types.go b/providers/dns/shellrent/internal/types.go index a27b06347..6bdd82330 100644 --- a/providers/dns/shellrent/internal/types.go +++ b/providers/dns/shellrent/internal/types.go @@ -7,6 +7,7 @@ import ( type Response[T any] struct { Base + Data T `json:"data"` } @@ -57,6 +58,7 @@ func (m *IntOrString) UnmarshalJSON(data []byte) error { raw := string(data) if data[0] == '"' { var err error + raw, err = strconv.Unquote(string(data)) if err != nil { return err diff --git a/providers/dns/shellrent/shellrent.go b/providers/dns/shellrent/shellrent.go index d1054b378..0cd33e19a 100644 --- a/providers/dns/shellrent/shellrent.go +++ b/providers/dns/shellrent/shellrent.go @@ -9,8 +9,10 @@ import ( "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/shellrent/internal" ) @@ -29,6 +31,8 @@ const ( const defaultTTL = 3600 +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + type reqKey struct { domainID int recordID int @@ -100,6 +104,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -120,9 +126,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { zone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { - if err != nil { - return fmt.Errorf("shellrent: could not find zone for domain %q: %w", domain, err) - } + return fmt.Errorf("shellrent: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.DomainName) @@ -158,6 +162,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() key, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("shellrent: unknown request key for '%s' '%s'", info.EffectiveFQDN, token) } @@ -167,6 +172,10 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("shellrent: delete record: %w", err) } + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + return nil } diff --git a/providers/dns/shellrent/shellrent.toml b/providers/dns/shellrent/shellrent.toml index 1e19e2d0d..05b6517fc 100644 --- a/providers/dns/shellrent/shellrent.toml +++ b/providers/dns/shellrent/shellrent.toml @@ -7,7 +7,7 @@ Since = "v4.16.0" Example = ''' SHELLRENT_USERNAME=xxxx \ SHELLRENT_TOKEN=yyyy \ -lego --email you@example.com --dns shellrent -d '*.example.com' -d example.com run +lego --dns shellrent -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,10 +15,10 @@ lego --email you@example.com --dns shellrent -d '*.example.com' -d example.com r SHELLRENT_USERNAME = "Username" SHELLRENT_TOKEN = "Token" [Configuration.Additional] - SHELLRENT_POLLING_INTERVAL = "Time between DNS propagation check" - SHELLRENT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - SHELLRENT_TTL = "The TTL of the TXT record used for the DNS challenge" - SHELLRENT_HTTP_TIMEOUT = "API request timeout" + SHELLRENT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + SHELLRENT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" + SHELLRENT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" + SHELLRENT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.shellrent.com/section/api2" diff --git a/providers/dns/shellrent/shellrent_test.go b/providers/dns/shellrent/shellrent_test.go index e5d529917..8c4e3f6bf 100644 --- a/providers/dns/shellrent/shellrent_test.go +++ b/providers/dns/shellrent/shellrent_test.go @@ -47,6 +47,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -117,6 +118,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -130,6 +132,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/simply/internal/client.go b/providers/dns/simply/internal/client.go index b57bf2102..0c0655463 100644 --- a/providers/dns/simply/internal/client.go +++ b/providers/dns/simply/internal/client.go @@ -16,7 +16,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) -const defaultBaseURL = "https://api.simply.com/1/" +const defaultBaseURL = "https://api.simply.com/2/" // Client is a Simply.com API client. type Client struct { @@ -28,7 +28,7 @@ type Client struct { } // NewClient creates a new Client. -func NewClient(accountName string, apiKey string) (*Client, error) { +func NewClient(accountName, apiKey string) (*Client, error) { if accountName == "" { return nil, errors.New("credentials missing: accountName") } @@ -60,6 +60,7 @@ func (c *Client) GetRecords(ctx context.Context, zoneName string) ([]Record, err } result := &apiResponse[[]Record, json.RawMessage]{} + err = c.do(req, result) if err != nil { return nil, err @@ -78,6 +79,7 @@ func (c *Client) AddRecord(ctx context.Context, zoneName string, record Record) } result := &apiResponse[json.RawMessage, recordHeader]{} + err = c.do(req, result) if err != nil { return 0, err @@ -110,11 +112,13 @@ func (c *Client) DeleteRecord(ctx context.Context, zoneName string, id int64) er return c.do(req, &apiResponse[json.RawMessage, json.RawMessage]{}) } -func (c *Client) createEndpoint(zoneName string, uri string) *url.URL { - return c.baseURL.JoinPath(c.accountName, c.apiKey, "my", "products", zoneName, "dns", "records", strings.TrimSuffix(uri, "/")) +func (c *Client) createEndpoint(zoneName, uri string) *url.URL { + return c.baseURL.JoinPath("my", "products", zoneName, "dns", "records", strings.TrimSuffix(uri, "/")) } func (c *Client) do(req *http.Request, result Response) error { + req.SetBasicAuth(c.accountName, c.apiKey) + resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) diff --git a/providers/dns/simply/internal/client_test.go b/providers/dns/simply/internal/client_test.go index c9b97e94c..b0bdac6b3 100644 --- a/providers/dns/simply/internal/client_test.go +++ b/providers/dns/simply/internal/client_test.go @@ -1,27 +1,40 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("accountname", "apikey") + if err != nil { + return nil, err + } + + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("accountname", "apikey")) +} + func TestClient_GetRecords(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /my/products/azone01/dns/records", + servermock.ResponseFromFixture("get_records.json")). + Build(t) - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodGet, http.StatusOK, "get_records.json")) - - records, err := client.GetRecords(context.Background(), "azone01") + records, err := client.GetRecords(t.Context(), "azone01") require.NoError(t, err) expected := []Record{ @@ -63,20 +76,23 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecords_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /my/products/azone01/dns/records", + servermock.ResponseFromFixture("bad_auth_error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodGet, http.StatusBadRequest, "bad_auth_error.json")) - - records, err := client.GetRecords(context.Background(), "azone01") + records, err := client.GetRecords(t.Context(), "azone01") require.Error(t, err) assert.Nil(t, records) } func TestClient_AddRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodPost, http.StatusOK, "add_record.json")) + client := mockBuilder(). + Route("POST /my/products/azone01/dns/records", + servermock.ResponseFromFixture("add_record.json")). + Build(t) record := Record{ Name: "arecord01", @@ -86,16 +102,18 @@ func TestClient_AddRecord(t *testing.T) { Priority: 0, } - recordID, err := client.AddRecord(context.Background(), "azone01", record) + recordID, err := client.AddRecord(t.Context(), "azone01", record) require.NoError(t, err) assert.EqualValues(t, 123456789, recordID) } func TestClient_AddRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodPost, http.StatusNotFound, "bad_zone_error.json")) + client := mockBuilder(). + Route("POST /my/products/azone01/dns/records", + servermock.ResponseFromFixture("bad_zone_error.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) record := Record{ Name: "arecord01", @@ -105,16 +123,17 @@ func TestClient_AddRecord_error(t *testing.T) { Priority: 0, } - recordID, err := client.AddRecord(context.Background(), "azone01", record) + recordID, err := client.AddRecord(t.Context(), "azone01", record) require.Error(t, err) assert.Zero(t, recordID) } func TestClient_EditRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodPut, http.StatusOK, "success.json")) + client := mockBuilder(). + Route("PUT /my/products/azone01/dns/records/123456789", + servermock.ResponseFromFixture("success.json")). + Build(t) record := Record{ Name: "arecord01", @@ -124,14 +143,16 @@ func TestClient_EditRecord(t *testing.T) { Priority: 0, } - err := client.EditRecord(context.Background(), "azone01", 123456789, record) + err := client.EditRecord(t.Context(), "azone01", 123456789, record) require.NoError(t, err) } func TestClient_EditRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodPut, http.StatusNotFound, "invalid_record_id.json")) + client := mockBuilder(). + Route("PUT /my/products/azone01/dns/records/123456789", + servermock.ResponseFromFixture("invalid_record_id.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) record := Record{ Name: "arecord01", @@ -141,68 +162,27 @@ func TestClient_EditRecord_error(t *testing.T) { Priority: 0, } - err := client.EditRecord(context.Background(), "azone01", 123456789, record) + err := client.EditRecord(t.Context(), "azone01", 123456789, record) require.Error(t, err) } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /my/products/azone01/dns/records/123456789", + servermock.ResponseFromFixture("success.json")). + Build(t) - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodDelete, http.StatusOK, "success.json")) - - err := client.DeleteRecord(context.Background(), "azone01", 123456789) + err := client.DeleteRecord(t.Context(), "azone01", 123456789) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /my/products/azone01/dns/records/123456789", + servermock.ResponseFromFixture("invalid_record_id.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodDelete, http.StatusNotFound, "invalid_record_id.json")) - - err := client.DeleteRecord(context.Background(), "azone01", 123456789) + err := client.DeleteRecord(t.Context(), "azone01", 123456789) require.Error(t, err) } - -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client, err := NewClient("accountname", "apikey") - require.NoError(t, err) - - client.baseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func mockHandler(method string, statusCode int, filename string) func(http.ResponseWriter, *http.Request) { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - if filename == "" { - rw.WriteHeader(statusCode) - return - } - - file, err := os.Open(filepath.FromSlash(path.Join("./fixtures", filename))) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} diff --git a/providers/dns/simply/simply.go b/providers/dns/simply/simply.go index 2433c4e06..fc3afd310 100644 --- a/providers/dns/simply/simply.go +++ b/providers/dns/simply/simply.go @@ -9,8 +9,10 @@ import ( "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/simply/internal" ) @@ -27,6 +29,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { AccountName string @@ -96,6 +100,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -159,6 +165,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("simply: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } diff --git a/providers/dns/simply/simply.toml b/providers/dns/simply/simply.toml index 15cf7feb2..a838e245a 100644 --- a/providers/dns/simply/simply.toml +++ b/providers/dns/simply/simply.toml @@ -7,7 +7,7 @@ Since = "v4.4.0" Example = ''' SIMPLY_ACCOUNT_NAME=xxxxxx \ SIMPLY_API_KEY=yyyyyy \ -lego --email you@example.com --dns simply -d '*.example.com' -d example.com run +lego --dns simply -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,10 +15,11 @@ lego --email you@example.com --dns simply -d '*.example.com' -d example.com run SIMPLY_ACCOUNT_NAME = "Account name" SIMPLY_API_KEY = "API key" [Configuration.Additional] - SIMPLY_POLLING_INTERVAL = "Time between DNS propagation check" - SIMPLY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - SIMPLY_TTL = "The TTL of the TXT record used for the DNS challenge" - SIMPLY_HTTP_TIMEOUT = "API request timeout" + SIMPLY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + SIMPLY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" + SIMPLY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + SIMPLY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.simply.com/en/docs/api/" + Spec = "https://generator.swagger.io/?url=https://api.simply.com/2/openapi.json#/" diff --git a/providers/dns/simply/simply_test.go b/providers/dns/simply/simply_test.go index ace8e0b72..e6de60d43 100644 --- a/providers/dns/simply/simply_test.go +++ b/providers/dns/simply/simply_test.go @@ -53,6 +53,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -121,6 +122,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -134,6 +136,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/sonic/internal/client.go b/providers/dns/sonic/internal/client.go index aac85c636..cf8f7f067 100644 --- a/providers/dns/sonic/internal/client.go +++ b/providers/dns/sonic/internal/client.go @@ -42,7 +42,7 @@ func NewClient(userID, apiKey string) (*Client, error) { // SetRecord creates or updates a TXT records. // Sonic does not provide a delete record API endpoint. // https://public-api.sonic.net/dyndns#updating_or_adding_host_records -func (c *Client) SetRecord(ctx context.Context, hostname string, value string, ttl int) error { +func (c *Client) SetRecord(ctx context.Context, hostname, value string, ttl int) error { payload := &Record{ UserID: c.userID, APIKey: c.apiKey, @@ -83,6 +83,7 @@ func (c *Client) SetRecord(ctx context.Context, hostname string, value string, t } r := APIResponse{} + err = json.Unmarshal(raw, &r) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) diff --git a/providers/dns/sonic/internal/client_test.go b/providers/dns/sonic/internal/client_test.go index ac711387e..751ccee8f 100644 --- a/providers/dns/sonic/internal/client_test.go +++ b/providers/dns/sonic/internal/client_test.go @@ -1,32 +1,23 @@ package internal import ( - "context" - "fmt" - "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, body string) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/host", func(rw http.ResponseWriter, req *http.Request) { - _, _ = fmt.Fprintln(rw, body) - }) - +func setupClient(server *httptest.Server) (*Client, error) { client, err := NewClient("foo", "secret") - require.NoError(t, err) + if err != nil { + return nil, err + } client.baseURL = server.URL + client.HTTPClient = server.Client() - return client + return client, nil } func TestClient_SetRecord(t *testing.T) { @@ -51,9 +42,13 @@ func TestClient_SetRecord(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client := setupTest(t, test.response) + client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders()). + Route("PUT /host", + servermock.RawStringResponse(test.response), + servermock.CheckRequestJSONBody(`{"userid":"foo","apikey":"secret","hostname":"example.com","value":"txttxttxt","ttl":10,"type":"TXT"}`)). + Build(t) - err := client.SetRecord(context.Background(), "example.com", "txttxttxt", 10) + err := client.SetRecord(t.Context(), "example.com", "txttxttxt", 10) test.assert(t, err) }) } diff --git a/providers/dns/sonic/sonic.go b/providers/dns/sonic/sonic.go index 19c5769b3..5bda2b533 100644 --- a/providers/dns/sonic/sonic.go +++ b/providers/dns/sonic/sonic.go @@ -8,8 +8,10 @@ import ( "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/sonic/internal" ) @@ -27,6 +29,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { UserID string @@ -88,6 +92,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{client: client, config: config}, nil } diff --git a/providers/dns/sonic/sonic.toml b/providers/dns/sonic/sonic.toml index f871d3f94..cb501e923 100644 --- a/providers/dns/sonic/sonic.toml +++ b/providers/dns/sonic/sonic.toml @@ -7,7 +7,7 @@ Since = "v4.4.0" Example = ''' SONIC_USER_ID=12345 \ SONIC_API_KEY=4d6fbf2f9ab0fa11697470918d37625851fc0c51 \ -lego --email you@example.com --dns sonic -d '*.example.com' -d example.com run +lego --dns sonic -d '*.example.com' -d example.com run ''' Additional = ''' @@ -34,11 +34,11 @@ Hostname should be the toplevel domain managed e.g. `example.com` not `www.examp SONIC_USER_ID = "User ID" SONIC_API_KEY = "API Key" [Configuration.Additional] - SONIC_POLLING_INTERVAL = "Time between DNS propagation check" - SONIC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - SONIC_TTL = "The TTL of the TXT record used for the DNS challenge" - SONIC_HTTP_TIMEOUT = "API request timeout" - SONIC_SEQUENCE_INTERVAL = "Time between sequential requests" + SONIC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + SONIC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + SONIC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + SONIC_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" + SONIC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://public-api.sonic.net/dyndns/" diff --git a/providers/dns/sonic/sonic_test.go b/providers/dns/sonic/sonic_test.go index f9087f8e3..7dc7fc586 100644 --- a/providers/dns/sonic/sonic_test.go +++ b/providers/dns/sonic/sonic_test.go @@ -49,6 +49,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -119,6 +120,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -132,6 +134,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/spaceship/internal/client.go b/providers/dns/spaceship/internal/client.go new file mode 100644 index 000000000..e690fa467 --- /dev/null +++ b/providers/dns/spaceship/internal/client.go @@ -0,0 +1,161 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const defaultBaseURL = "https://spaceship.dev/api/v1/" + +// Client the Spaceship API client. +type Client struct { + apiKey string + apiSecret string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(apiKey, apiSecret string) (*Client, error) { + if apiKey == "" || apiSecret == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + apiKey: apiKey, + apiSecret: apiSecret, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) do(req *http.Request, result any) error { + req.Header.Add("X-Api-Secret", c.apiSecret) + req.Header.Add("X-Api-Key", c.apiKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func (c *Client) AddRecord(ctx context.Context, domain string, record Record) error { + endpoint := c.baseURL.JoinPath("dns", "records", domain) + + req, err := newJSONRequest(ctx, http.MethodPut, endpoint, Foo{Items: []Record{record}}) + if err != nil { + return err + } + + err = c.do(req, nil) + if err != nil { + return err + } + + return nil +} + +func (c *Client) DeleteRecord(ctx context.Context, domain string, record Record) error { + endpoint := c.baseURL.JoinPath("dns", "records", domain) + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, []Record{record}) + if err != nil { + return err + } + + err = c.do(req, nil) + if err != nil { + return err + } + + return nil +} + +func (c *Client) GetRecords(ctx context.Context, domain string) ([]Record, error) { + endpoint := c.baseURL.JoinPath("dns", "records", domain) + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result GetRecordsResponse + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result.Items, nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/spaceship/internal/client_test.go b/providers/dns/spaceship/internal/client_test.go new file mode 100644 index 000000000..f32843652 --- /dev/null +++ b/providers/dns/spaceship/internal/client_test.go @@ -0,0 +1,124 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("key", "secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With("X-Api-Key", "key"). + With("X-Api-Secret", "secret"), + ) +} + +func TestClient_AddRecord(t *testing.T) { + client := mockBuilder(). + Route("PUT /dns/records/example.com", nil, + servermock.CheckRequestJSONBody(`{"items":[{"type":"TXT","name":"@","ttl":60}]}`)). + Build(t) + + record := Record{ + Type: "TXT", + Name: "@", + TTL: 60, + } + + err := client.AddRecord(t.Context(), "example.com", record) + require.NoError(t, err) +} + +func TestClient_AddRecord_error(t *testing.T) { + client := mockBuilder(). + Route("PUT /dns/records/example.com", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnprocessableEntity)). + Build(t) + + record := Record{ + Type: "TXT", + Name: "@", + TTL: 60, + } + + err := client.AddRecord(t.Context(), "example.com", record) + require.EqualError(t, err, "^$, name: The domain name contains invalid characters") +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /dns/records/example.com", nil, + servermock.CheckRequestJSONBody(`[{"type":"TXT","name":"@","ttl":60}]`)). + Build(t) + + record := Record{ + Type: "TXT", + Name: "@", + TTL: 60, + } + + err := client.DeleteRecord(t.Context(), "example.com", record) + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /dns/records/example.com", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnprocessableEntity)). + Build(t) + + record := Record{ + Type: "TXT", + Name: "@", + TTL: 60, + } + + err := client.DeleteRecord(t.Context(), "example.com", record) + require.EqualError(t, err, "^$, name: The domain name contains invalid characters") +} + +func TestClient_GetRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/records/example.com", + servermock.ResponseFromFixture("get-records.json")). + Build(t) + + records, err := client.GetRecords(t.Context(), "example.com") + require.NoError(t, err) + + expected := []Record{ + {Type: "A", Name: "@", TTL: 3600}, + } + + assert.Equal(t, expected, records) +} + +func TestClient_GetRecords_error(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/records/example.com", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnprocessableEntity)). + Build(t) + + _, err := client.GetRecords(t.Context(), "example.com") + require.EqualError(t, err, "^$, name: The domain name contains invalid characters") +} diff --git a/providers/dns/spaceship/internal/fixtures/error.json b/providers/dns/spaceship/internal/fixtures/error.json new file mode 100644 index 000000000..facf97e58 --- /dev/null +++ b/providers/dns/spaceship/internal/fixtures/error.json @@ -0,0 +1,9 @@ +{ + "detail": "^$", + "data": [ + { + "field": "name", + "details": "The domain name contains invalid characters" + } + ] +} diff --git a/providers/dns/spaceship/internal/fixtures/get-records.json b/providers/dns/spaceship/internal/fixtures/get-records.json new file mode 100644 index 000000000..cea2a895a --- /dev/null +++ b/providers/dns/spaceship/internal/fixtures/get-records.json @@ -0,0 +1,13 @@ +{ + "items": [ + { + "type": "A", + "name": "@", + "ttl": 3600, + "group": { + "type": "custom" + } + } + ], + "total": 100 +} diff --git a/providers/dns/spaceship/internal/types.go b/providers/dns/spaceship/internal/types.go new file mode 100644 index 000000000..bd318bb87 --- /dev/null +++ b/providers/dns/spaceship/internal/types.go @@ -0,0 +1,47 @@ +package internal + +import ( + "fmt" + "strings" +) + +type APIError struct { + Detail string `json:"detail"` + Data []struct { + Field string `json:"field"` + Details string `json:"details"` + } `json:"data"` +} + +func (a *APIError) Error() string { + msg := []string{a.Detail} + + for _, datum := range a.Data { + msg = append(msg, fmt.Sprintf("%s: %s", datum.Field, datum.Details)) + } + + return strings.Join(msg, ", ") +} + +type Foo struct { + Force bool `json:"force,omitempty"` + Items []Record `json:"items,omitempty"` +} + +type Record struct { + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` + Address string `json:"address,omitempty"` + Nameserver string `json:"nameserver,omitempty"` + AliasName string `json:"aliasName,omitempty"` + Pointer string `json:"pointer,omitempty"` + CName string `json:"cname,omitempty"` + Exchange string `json:"exchange,omitempty"` + TTL int `json:"ttl,omitempty"` +} + +type GetRecordsResponse struct { + Items []Record `json:"items"` + Total int `json:"total"` +} diff --git a/providers/dns/spaceship/spaceship.go b/providers/dns/spaceship/spaceship.go new file mode 100644 index 000000000..e34c584c5 --- /dev/null +++ b/providers/dns/spaceship/spaceship.go @@ -0,0 +1,157 @@ +// Package spaceship implements a DNS provider for solving the DNS-01 challenge using Spaceship. +package spaceship + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/spaceship/internal" +) + +// Environment variables names. +const ( + envNamespace = "SPACESHIP_" + + EnvAPIKey = envNamespace + "API_KEY" + EnvAPISecret = envNamespace + "API_SECRET" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIKey string + APISecret string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Spaceship. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey, EnvAPISecret) + if err != nil { + return nil, fmt.Errorf("spaceship: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + config.APISecret = values[EnvAPISecret] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Spaceship. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("spaceship: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.APIKey, config.APISecret) + if err != nil { + return nil, fmt.Errorf("spaceship: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("spaceship: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("spaceship: %w", err) + } + + record := internal.Record{ + Type: "TXT", + Name: subDomain, + Value: info.Value, + TTL: d.config.TTL, + } + + err = d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record) + if err != nil { + return fmt.Errorf("spaceship: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("spaceship: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("spaceship: %w", err) + } + + record := internal.Record{ + Type: "TXT", + Name: subDomain, + Value: info.Value, + } + + err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), record) + if err != nil { + return fmt.Errorf("spaceship: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/spaceship/spaceship.toml b/providers/dns/spaceship/spaceship.toml new file mode 100644 index 000000000..e9abcd408 --- /dev/null +++ b/providers/dns/spaceship/spaceship.toml @@ -0,0 +1,24 @@ +Name = "Spaceship" +Description = '''''' +URL = "https://www.spaceship.com/" +Code = "spaceship" +Since = "v4.22.0" + +Example = ''' +SPACESHIP_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +SPACESHIP_API_SECRET="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns spaceship -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + SPACESHIP_API_KEY = "API key" + SPACESHIP_API_SECRET = "API secret" + [Configuration.Additional] + SPACESHIP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + SPACESHIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + SPACESHIP_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + SPACESHIP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://docs.spaceship.dev/#tag/DNS-records" diff --git a/providers/dns/spaceship/spaceship_test.go b/providers/dns/spaceship/spaceship_test.go new file mode 100644 index 000000000..d4eb37d88 --- /dev/null +++ b/providers/dns/spaceship/spaceship_test.go @@ -0,0 +1,146 @@ +package spaceship + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIKey, EnvAPISecret).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "key", + EnvAPISecret: "secret", + }, + }, + { + desc: "missing API key", + envVars: map[string]string{ + EnvAPIKey: "", + EnvAPISecret: "secret", + }, + expected: "spaceship: some credentials information are missing: SPACESHIP_API_KEY", + }, + { + desc: "missing API secret", + envVars: map[string]string{ + EnvAPIKey: "key", + EnvAPISecret: "", + }, + expected: "spaceship: some credentials information are missing: SPACESHIP_API_SECRET", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "spaceship: some credentials information are missing: SPACESHIP_API_KEY,SPACESHIP_API_SECRET", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + apiSecret string + expected string + }{ + { + desc: "success", + apiKey: "key", + apiSecret: "secret", + }, + { + desc: "missing API key", + apiSecret: "secret", + expected: "spaceship: credentials missing", + }, + { + desc: "missing API secret", + apiKey: "key", + expected: "spaceship: credentials missing", + }, + { + desc: "missing credentials", + expected: "spaceship: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + config.APISecret = test.apiSecret + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/stackpath/internal/client.go b/providers/dns/stackpath/internal/client.go index bd11bf235..8a40a4093 100644 --- a/providers/dns/stackpath/internal/client.go +++ b/providers/dns/stackpath/internal/client.go @@ -25,13 +25,13 @@ type Client struct { } // NewClient creates a new Client. -func NewClient(ctx context.Context, stackID, clientID, clientSecret string) *Client { +func NewClient(stackID string, hc *http.Client) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ baseURL: baseURL, stackID: stackID, - httpClient: createOAuthClient(ctx, clientID, clientSecret), + httpClient: hc, } } @@ -55,6 +55,7 @@ func (c *Client) GetZones(ctx context.Context, domain string) (*Zone, error) { req.URL.RawQuery = query.Encode() var zones Zones + err = c.do(req, &zones) if err != nil { return nil, err @@ -82,6 +83,7 @@ func (c *Client) GetZoneRecords(ctx context.Context, name string, zone *Zone) ([ req.URL.RawQuery = query.Encode() var records Records + err = c.do(req, &records) if err != nil { return nil, err @@ -177,6 +179,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errResp := &ErrorResponse{} + err := json.Unmarshal(raw, errResp) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/stackpath/internal/client_test.go b/providers/dns/stackpath/internal/client_test.go index 2de1d4761..baac84397 100644 --- a/providers/dns/stackpath/internal/client_test.go +++ b/providers/dns/stackpath/internal/client_test.go @@ -1,50 +1,38 @@ package internal import ( - "context" "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("STACK_ID", server.Client()) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.baseURL, _ = url.Parse(server.URL + "/") - client := NewClient(context.Background(), "STACK_ID", "CLIENT_ID", "CLIENT_SECRET") - client.httpClient = server.Client() - client.baseURL, _ = url.Parse(server.URL + "/") - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestClient_GetZoneRecords(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /STACK_ID/zones/A/records", + servermock.ResponseFromFixture("get_zone_records.json"), + servermock.CheckQueryParameter().Strict(). + With("page_request.filter", "name='foo1' and type='TXT'")). + Build(t) - mux.HandleFunc("/STACK_ID/zones/A/records", func(w http.ResponseWriter, _ *http.Request) { - content := ` - { - "records": [ - {"id":"1","name":"foo1","type":"TXT","ttl":120,"data":"txtTXTtxt"}, - {"id":"2","name":"foo2","type":"TXT","ttl":121,"data":"TXTtxtTXT"} - ] - }` - - _, err := w.Write([]byte(content)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - records, err := client.GetZoneRecords(context.Background(), "foo1", &Zone{ID: "A", Domain: "test"}) + records, err := client.GetZoneRecords(t.Context(), "foo1", &Zone{ID: "A", Domain: "test"}) require.NoError(t, err) expected := []Record{ @@ -56,73 +44,30 @@ func TestClient_GetZoneRecords(t *testing.T) { } func TestClient_GetZoneRecords_apiError(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/STACK_ID/zones/A/records", func(w http.ResponseWriter, _ *http.Request) { - content := ` + client := mockBuilder(). + Route("GET /STACK_ID/zones/A/records", + servermock.RawStringResponse(` { "code": 401, "error": "an unauthorized request is attempted." -}` +}`).WithStatusCode(http.StatusUnauthorized)). + Build(t) - w.WriteHeader(http.StatusUnauthorized) - _, err := w.Write([]byte(content)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - _, err := client.GetZoneRecords(context.Background(), "foo1", &Zone{ID: "A", Domain: "test"}) + _, err := client.GetZoneRecords(t.Context(), "foo1", &Zone{ID: "A", Domain: "test"}) expected := &ErrorResponse{Code: 401, Message: "an unauthorized request is attempted."} assert.Equal(t, expected, err) } func TestClient_GetZones(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /STACK_ID/zones", + servermock.ResponseFromFixture("get_zones.json"), + servermock.CheckQueryParameter().Strict(). + With("page_request.filter", "domain='foo.com'")). + Build(t) - mux.HandleFunc("/STACK_ID/zones", func(w http.ResponseWriter, _ *http.Request) { - content := ` -{ - "pageInfo": { - "totalCount": "5", - "hasPreviousPage": false, - "hasNextPage": false, - "startCursor": "1", - "endCursor": "1" - }, - "zones": [ - { - "stackId": "my_stack", - "accountId": "my_account", - "id": "A", - "domain": "foo.com", - "version": "1", - "labels": { - "property1": "val1", - "property2": "val2" - }, - "created": "2018-10-07T02:31:49Z", - "updated": "2018-10-07T02:31:49Z", - "nameservers": [ - "1.1.1.1" - ], - "verified": "2018-10-07T02:31:49Z", - "status": "ACTIVE", - "disabled": false - } - ] -}` - - _, err := w.Write([]byte(content)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - zone, err := client.GetZones(context.Background(), "sub.foo.com") + zone, err := client.GetZones(t.Context(), "sub.foo.com") require.NoError(t, err) expected := &Zone{ID: "A", Domain: "foo.com"} diff --git a/providers/dns/stackpath/internal/fixtures/get_zone_records.json b/providers/dns/stackpath/internal/fixtures/get_zone_records.json new file mode 100644 index 000000000..1556d08fe --- /dev/null +++ b/providers/dns/stackpath/internal/fixtures/get_zone_records.json @@ -0,0 +1,6 @@ +{ + "records": [ + {"id":"1","name":"foo1","type":"TXT","ttl":120,"data":"txtTXTtxt"}, + {"id":"2","name":"foo2","type":"TXT","ttl":121,"data":"TXTtxtTXT"} + ] +} diff --git a/providers/dns/stackpath/internal/fixtures/get_zones.json b/providers/dns/stackpath/internal/fixtures/get_zones.json new file mode 100644 index 000000000..7630ef4fe --- /dev/null +++ b/providers/dns/stackpath/internal/fixtures/get_zones.json @@ -0,0 +1,30 @@ +{ + "pageInfo": { + "totalCount": "5", + "hasPreviousPage": false, + "hasNextPage": false, + "startCursor": "1", + "endCursor": "1" + }, + "zones": [ + { + "stackId": "my_stack", + "accountId": "my_account", + "id": "A", + "domain": "foo.com", + "version": "1", + "labels": { + "property1": "val1", + "property2": "val2" + }, + "created": "2018-10-07T02:31:49Z", + "updated": "2018-10-07T02:31:49Z", + "nameservers": [ + "1.1.1.1" + ], + "verified": "2018-10-07T02:31:49Z", + "status": "ACTIVE", + "disabled": false + } + ] +} diff --git a/providers/dns/stackpath/internal/identity.go b/providers/dns/stackpath/internal/identity.go index 5c6e6ab17..fa3e9df07 100644 --- a/providers/dns/stackpath/internal/identity.go +++ b/providers/dns/stackpath/internal/identity.go @@ -9,7 +9,7 @@ import ( const defaultAuthURL = "https://gateway.stackpath.com/identity/v1/oauth2/token" -func createOAuthClient(ctx context.Context, clientID, clientSecret string) *http.Client { +func CreateOAuthClient(ctx context.Context, clientID, clientSecret string) *http.Client { config := &clientcredentials.Config{ TokenURL: defaultAuthURL, ClientID: clientID, diff --git a/providers/dns/stackpath/stackpath.go b/providers/dns/stackpath/stackpath.go index 97cfd8aa3..2e193b8a9 100644 --- a/providers/dns/stackpath/stackpath.go +++ b/providers/dns/stackpath/stackpath.go @@ -8,9 +8,11 @@ import ( "fmt" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/stackpath/internal" ) @@ -27,6 +29,8 @@ const ( EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { ClientID string @@ -40,7 +44,7 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - TTL: env.GetOrDefaultInt(EnvTTL, 120), + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), } @@ -83,9 +87,14 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("stackpath: stack id missing") } - client := internal.NewClient(context.Background(), config.StackID, config.ClientID, config.ClientSecret) - - return &DNSProvider{config: config, client: client}, nil + return &DNSProvider{ + config: config, + client: internal.NewClient(config.StackID, + clientdebug.Wrap( + internal.CreateOAuthClient(context.Background(), config.ClientID, config.ClientSecret), + ), + ), + }, nil } // Present creates a TXT record to fulfill the dns-01 challenge. diff --git a/providers/dns/stackpath/stackpath.toml b/providers/dns/stackpath/stackpath.toml index 307922ee2..b50e7035f 100644 --- a/providers/dns/stackpath/stackpath.toml +++ b/providers/dns/stackpath/stackpath.toml @@ -8,7 +8,7 @@ Example = ''' STACKPATH_CLIENT_ID=xxxxx \ STACKPATH_CLIENT_SECRET=yyyyy \ STACKPATH_STACK_ID=zzzzz \ -lego --email you@example.com --dns stackpath -d '*.example.com' -d example.com run +lego --dns stackpath -d '*.example.com' -d example.com run ''' [Configuration] @@ -17,9 +17,9 @@ lego --email you@example.com --dns stackpath -d '*.example.com' -d example.com r STACKPATH_CLIENT_SECRET = "Client secret" STACKPATH_STACK_ID = "Stack ID" [Configuration.Additional] - STACKPATH_POLLING_INTERVAL = "Time between DNS propagation check" - STACKPATH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - STACKPATH_TTL = "The TTL of the TXT record used for the DNS challenge" + STACKPATH_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + STACKPATH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + STACKPATH_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" [Links] API = "https://developer.stackpath.com/en/api/dns/#tag/Zone" diff --git a/providers/dns/stackpath/stackpath_test.go b/providers/dns/stackpath/stackpath_test.go index f8b83140f..a4b959222 100644 --- a/providers/dns/stackpath/stackpath_test.go +++ b/providers/dns/stackpath/stackpath_test.go @@ -72,6 +72,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -137,6 +138,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -150,6 +152,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/syse/internal/client.go b/providers/dns/syse/internal/client.go new file mode 100644 index 000000000..8cb801469 --- /dev/null +++ b/providers/dns/syse/internal/client.go @@ -0,0 +1,131 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://www.syse.no/api" + +// Client the Syse API client. +type Client struct { + credentials map[string]string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(credentials map[string]string) (*Client, error) { + if len(credentials) == 0 { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + credentials: credentials, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) CreateRecord(ctx context.Context, zone string, record Record) (*Record, error) { + endpoint := c.BaseURL.JoinPath("dns", zone) + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return nil, err + } + + req.SetBasicAuth(zone, c.credentials[zone]) + + result := new(Record) + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +func (c *Client) DeleteRecord(ctx context.Context, zone, recordID string) error { + endpoint := c.BaseURL.JoinPath("dns", zone, recordID) + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + req.SetBasicAuth(zone, c.credentials[zone]) + + return c.do(req, nil) +} + +func (c *Client) do(req *http.Request, result any) error { + useragent.SetHeader(req.Header) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + raw, _ := io.ReadAll(resp.Body) + + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} diff --git a/providers/dns/syse/internal/client_test.go b/providers/dns/syse/internal/client_test.go new file mode 100644 index 000000000..88416aa88 --- /dev/null +++ b/providers/dns/syse/internal/client_test.go @@ -0,0 +1,102 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(map[string]string{ + "example.com": "secret", + }) + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + ) +} + +func TestClient_CreateRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/example.com", + servermock.ResponseFromFixture("create_record.json"), + servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). + Build(t) + + record := Record{ + Type: "TXT", + Prefix: "_acme-challenge", + Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + Active: true, + TTL: 120, + } + + result, err := client.CreateRecord(t.Context(), "example.com", record) + require.NoError(t, err) + + expected := &Record{ + ID: "1234", + Type: "TXT", + Prefix: "_acme-challenge", + Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + Active: true, + TTL: 120, + } + + assert.Equal(t, expected, result) +} + +func TestClient_CreateRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/example.com", + servermock.RawStringResponse(http.StatusText(http.StatusUnauthorized)). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + record := Record{ + Type: "TXT", + Prefix: "_acme-challenge", + Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + Active: true, + TTL: 120, + } + + _, err := client.CreateRecord(t.Context(), "example.com", record) + require.EqualError(t, err, "unexpected status code: [status code: 401] body: Unauthorized") +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /dns/example.com/1234", + servermock.Noop()). + Build(t) + + err := client.DeleteRecord(t.Context(), "example.com", "1234") + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /dns/example.com/1234", + servermock.RawStringResponse(http.StatusText(http.StatusUnauthorized)). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + err := client.DeleteRecord(t.Context(), "example.com", "1234") + require.EqualError(t, err, "unexpected status code: [status code: 401] body: Unauthorized") +} diff --git a/providers/dns/syse/internal/fixtures/create_record-request.json b/providers/dns/syse/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..549a0f60f --- /dev/null +++ b/providers/dns/syse/internal/fixtures/create_record-request.json @@ -0,0 +1,7 @@ +{ + "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "active": true, + "ttl": 120, + "prefix": "_acme-challenge", + "type": "TXT" +} diff --git a/providers/dns/syse/internal/fixtures/create_record.json b/providers/dns/syse/internal/fixtures/create_record.json new file mode 100644 index 000000000..b598779c6 --- /dev/null +++ b/providers/dns/syse/internal/fixtures/create_record.json @@ -0,0 +1,8 @@ +{ + "id": "1234", + "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "active": true, + "ttl": 120, + "prefix": "_acme-challenge", + "type": "TXT" +} diff --git a/providers/dns/syse/internal/types.go b/providers/dns/syse/internal/types.go new file mode 100644 index 000000000..4b90205e1 --- /dev/null +++ b/providers/dns/syse/internal/types.go @@ -0,0 +1,11 @@ +package internal + +type Record struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Prefix string `json:"prefix,omitempty"` + Content string `json:"content,omitempty"` + Priority int `json:"prio,omitempty"` + TTL int `json:"ttl,omitempty"` + Active bool `json:"active,omitempty"` +} diff --git a/providers/dns/syse/syse.go b/providers/dns/syse/syse.go new file mode 100644 index 000000000..29633280c --- /dev/null +++ b/providers/dns/syse/syse.go @@ -0,0 +1,186 @@ +// Package syse implements a DNS provider for solving the DNS-01 challenge using Syse. +package syse + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/syse/internal" +) + +// Environment variables names. +const ( + envNamespace = "SYSE_" + + EnvCredentials = envNamespace + "CREDENTIALS" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Credentials map[string]string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 1200*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + recordIDs map[string]string + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for Syse. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvCredentials) + if err != nil { + return nil, fmt.Errorf("syse: %w", err) + } + + config := NewDefaultConfig() + + credentials, err := env.ParsePairs(values[EnvCredentials]) + if err != nil { + return nil, fmt.Errorf("syse: credentials: %w", err) + } + + config.Credentials = credentials + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Syse. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("syse: the configuration of the DNS provider is nil") + } + + if len(config.Credentials) == 0 { + return nil, errors.New("syse: missing credentials") + } + + for domain, password := range config.Credentials { + if domain == "" { + return nil, fmt.Errorf(`syse: missing domain: "%s:%s"`, domain, password) + } + + if password == "" { + return nil, fmt.Errorf(`syse: missing password: "%s:%s"`, domain, password) + } + } + + client, err := internal.NewClient(config.Credentials) + if err != nil { + return nil, fmt.Errorf("syse: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]string), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("syse: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("syse: %w", err) + } + + record := internal.Record{ + Type: "TXT", + Prefix: subDomain, + Content: info.Value, + TTL: d.config.TTL, + Active: true, + } + + newRecord, err := d.client.CreateRecord(context.Background(), dns01.UnFqdn(authZone), record) + if err != nil { + return fmt.Errorf("syse: create record: %w", err) + } + + d.recordIDsMu.Lock() + d.recordIDs[token] = newRecord.ID + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("syse: could not find zone for domain %q: %w", domain, err) + } + + // gets the record's unique ID from when we created it + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + + if !ok { + return fmt.Errorf("syse: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID) + if err != nil { + return fmt.Errorf("syse: delete record: %w", err) + } + + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/syse/syse.toml b/providers/dns/syse/syse.toml new file mode 100644 index 000000000..b5b1fdf47 --- /dev/null +++ b/providers/dns/syse/syse.toml @@ -0,0 +1,25 @@ +Name = "Syse" +Description = '''''' +URL = "https://www.syse.no/" +Code = "syse" +Since = "v4.30.0" + +Example = ''' +SYSE_CREDENTIALS=example.com:password \ +lego --dns syse -d '*.example.com' -d example.com run + +SYSE_CREDENTIALS=example.org:password1,example.com:password2 \ +lego --dns syse -d '*.example.org' -d example.org -d '*.example.com' -d example.com +''' + +[Configuration] + [Configuration.Credentials] + SYSE_CREDENTIALS = "Comma-separated list of `zone:password` credential pairs" + [Configuration.Additional] + SYSE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + SYSE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 1200)" + SYSE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + SYSE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://www.syse.no/api/dns" diff --git a/providers/dns/syse/syse_test.go b/providers/dns/syse/syse_test.go new file mode 100644 index 000000000..a4472aa7c --- /dev/null +++ b/providers/dns/syse/syse_test.go @@ -0,0 +1,220 @@ +package syse + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvCredentials).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvCredentials: "example.org:123", + }, + }, + { + desc: "success multiple domains", + envVars: map[string]string{ + EnvCredentials: "example.org:123,example.com:456,example.net:789", + }, + }, + { + desc: "invalid credentials", + envVars: map[string]string{ + EnvCredentials: ",", + }, + expected: `syse: credentials: incorrect pair: `, + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvCredentials: "example.org:", + }, + expected: `syse: missing password: "example.org:"`, + }, + { + desc: "missing domain", + envVars: map[string]string{ + EnvCredentials: ":123", + }, + expected: `syse: missing domain: ":123"`, + }, + { + desc: "invalid credentials, partial", + envVars: map[string]string{ + EnvCredentials: "example.org:123,example.net", + }, + expected: "syse: credentials: incorrect pair: example.net", + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvCredentials: "", + }, + expected: "syse: some credentials information are missing: SYSE_CREDENTIALS", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + creds map[string]string + expected string + }{ + { + desc: "success", + creds: map[string]string{"example.org": "123"}, + }, + { + desc: "success multiple domains", + creds: map[string]string{ + "example.org": "123", + "example.com": "456", + "example.net": "789", + }, + }, + { + desc: "missing credentials", + expected: "syse: missing credentials", + }, + { + desc: "missing domain", + creds: map[string]string{"": "123"}, + expected: `syse: missing domain: ":123"`, + }, + { + desc: "missing password", + creds: map[string]string{"example.org": ""}, + expected: `syse: missing password: "example.org:"`, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Credentials = test.creds + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Credentials = map[string]string{ + "example.org": "secret", + } + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("/", servermock.DumpRequest()). + Route("POST /dns/example.com", + servermock.ResponseFromInternal("create_record.json"), + servermock.CheckRequestJSONBodyFromInternal("create_record-request.json")). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("DELETE /dns/example.com/1234", + servermock.Noop()). + Build(t) + + provider.recordIDs["abc"] = "1234" + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/technitium/internal/client.go b/providers/dns/technitium/internal/client.go index 312892e5a..965638b1d 100644 --- a/providers/dns/technitium/internal/client.go +++ b/providers/dns/technitium/internal/client.go @@ -103,7 +103,7 @@ func (c *Client) do(req *http.Request, result any) error { defer func() { _ = resp.Body.Close() }() - if resp.StatusCode > http.StatusBadRequest { + if resp.StatusCode >= http.StatusBadRequest { return parseError(req, resp) } @@ -125,6 +125,7 @@ func (c *Client) newFormRequest(ctx context.Context, endpoint *url.URL, payload if payload != nil { var err error + values, err = querystring.Values(payload) if err != nil { return nil, fmt.Errorf("failed to create request body: %w", err) @@ -149,6 +150,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIResponse[any] + err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/technitium/internal/client_test.go b/providers/dns/technitium/internal/client_test.go index 326c1e8eb..cd6914918 100644 --- a/providers/dns/technitium/internal/client_test.go +++ b/providers/dns/technitium/internal/client_test.go @@ -1,51 +1,39 @@ package internal import ( - "context" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient(server.URL, "secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - - return client + return client, nil + }, + servermock.CheckHeader().WithContentTypeFromURLEncoded()) } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "POST /api/zones/records/add", "add-record.json") + client := mockBuilder(). + Route("POST /api/zones/records/add", + servermock.ResponseFromFixture("add-record.json"), + servermock.CheckForm().Strict(). + With("domain", "_acme-challenge.example.com"). + With("text", "txtTXTtxt"). + With("type", "TXT"). + With("token", "secret")). + Build(t) record := Record{ Domain: "_acme-challenge.example.com", @@ -53,7 +41,7 @@ func TestClient_AddRecord(t *testing.T) { Text: "txtTXTtxt", } - newRecord, err := client.AddRecord(context.Background(), record) + newRecord, err := client.AddRecord(t.Context(), record) require.NoError(t, err) expected := &Record{Name: "example.com", Type: "A"} @@ -62,7 +50,10 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "POST /api/zones/records/add", "error.json") + client := mockBuilder(). + Route("POST /api/zones/records/add", + servermock.ResponseFromFixture("error.json")). + Build(t) record := Record{ Domain: "_acme-challenge.example.com", @@ -70,14 +61,22 @@ func TestClient_AddRecord_error(t *testing.T) { Text: "txtTXTtxt", } - _, err := client.AddRecord(context.Background(), record) + _, err := client.AddRecord(t.Context(), record) require.Error(t, err) assert.EqualError(t, err, "Status: error, ErrorMessage: error message, StackTrace: application stack trace, InnerErrorMessage: inner exception message") } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "POST /api/zones/records/delete", "delete-record.json") + client := mockBuilder(). + Route("POST /api/zones/records/delete", + servermock.ResponseFromFixture("delete-record.json"), + servermock.CheckForm().Strict(). + With("domain", "_acme-challenge.example.com"). + With("text", "txtTXTtxt"). + With("type", "TXT"). + With("token", "secret")). + Build(t) record := Record{ Domain: "_acme-challenge.example.com", @@ -85,12 +84,15 @@ func TestClient_DeleteRecord(t *testing.T) { Text: "txtTXTtxt", } - err := client.DeleteRecord(context.Background(), record) + err := client.DeleteRecord(t.Context(), record) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "POST /api/zones/records/delete", "error.json") + client := mockBuilder(). + Route("POST /api/zones/records/delete", + servermock.ResponseFromFixture("error.json")). + Build(t) record := Record{ Domain: "_acme-challenge.example.com", @@ -98,7 +100,7 @@ func TestClient_DeleteRecord_error(t *testing.T) { Text: "txtTXTtxt", } - err := client.DeleteRecord(context.Background(), record) + err := client.DeleteRecord(t.Context(), record) require.Error(t, err) assert.EqualError(t, err, "Status: error, ErrorMessage: error message, StackTrace: application stack trace, InnerErrorMessage: inner exception message") diff --git a/providers/dns/technitium/technitium.go b/providers/dns/technitium/technitium.go index 8ee3ccc06..fc60c09ad 100644 --- a/providers/dns/technitium/technitium.go +++ b/providers/dns/technitium/technitium.go @@ -8,8 +8,10 @@ import ( "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/technitium/internal" ) @@ -26,6 +28,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string @@ -84,6 +88,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, diff --git a/providers/dns/technitium/technitium.toml b/providers/dns/technitium/technitium.toml index 54502957f..ac1fc6466 100644 --- a/providers/dns/technitium/technitium.toml +++ b/providers/dns/technitium/technitium.toml @@ -7,7 +7,7 @@ Since = "v4.20.0" Example = ''' TECHNITIUM_SERVER_BASE_URL="https://localhost:5380" \ TECHNITIUM_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ -lego --email you@example.com --dns technitium -d '*.example.com' -d example.com run +lego --dns technitium -d '*.example.com' -d example.com run ''' Additional = ''' @@ -23,10 +23,10 @@ Technitium recommends to use it in production over the HTTP API. TECHNITIUM_SERVER_BASE_URL = "Server base URL" TECHNITIUM_API_TOKEN = "API token" [Configuration.Additional] - TECHNITIUM_POLLING_INTERVAL = "Time between DNS propagation check" - TECHNITIUM_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - TECHNITIUM_TTL = "The TTL of the TXT record used for the DNS challenge" - TECHNITIUM_HTTP_TIMEOUT = "API request timeout" + TECHNITIUM_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + TECHNITIUM_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + TECHNITIUM_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + TECHNITIUM_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://github.com/TechnitiumSoftware/DnsServer/blob/0f83d23e605956b66ac76921199e241d9cc061bd/APIDOCS.md" diff --git a/providers/dns/technitium/technitium_test.go b/providers/dns/technitium/technitium_test.go index da50b6fe6..4eee530fd 100644 --- a/providers/dns/technitium/technitium_test.go +++ b/providers/dns/technitium/technitium_test.go @@ -50,6 +50,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -122,6 +123,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -135,6 +137,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/tencentcloud/tencentcloud.go b/providers/dns/tencentcloud/tencentcloud.go index 448ca8ea6..00e41e93e 100644 --- a/providers/dns/tencentcloud/tencentcloud.go +++ b/providers/dns/tencentcloud/tencentcloud.go @@ -2,16 +2,18 @@ package tencentcloud import ( + "context" "errors" "fmt" "math" "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" + dnspod "github.com/go-acme/tencentclouddnspod/v20210323" "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" - dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323" ) // Environment variables names. @@ -29,6 +31,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { SecretID string @@ -114,7 +118,9 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - zone, err := d.getHostedZone(info.EffectiveFQDN) + ctx := context.Background() + + zone, err := d.getHostedZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("tencentcloud: failed to get hosted zone: %w", err) } @@ -133,7 +139,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { request.Value = common.StringPtr(info.Value) request.TTL = common.Uint64Ptr(uint64(d.config.TTL)) - _, err = d.client.CreateRecord(request) + _, err = dnspod.CreateRecordWithContext(ctx, d.client, request) if err != nil { return fmt.Errorf("dnspod: API call failed: %w", err) } @@ -145,12 +151,14 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - zone, err := d.getHostedZone(info.EffectiveFQDN) + ctx := context.Background() + + zone, err := d.getHostedZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("tencentcloud: failed to get hosted zone: %w", err) } - records, err := d.findTxtRecords(zone, info.EffectiveFQDN) + records, err := d.findTxtRecords(ctx, zone, info.EffectiveFQDN) if err != nil { return fmt.Errorf("tencentcloud: failed to find TXT records: %w", err) } @@ -161,7 +169,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { request.DomainId = zone.DomainId request.RecordId = record.RecordId - _, err := d.client.DeleteRecord(request) + _, err := dnspod.DeleteRecordWithContext(ctx, d.client, request) if err != nil { return fmt.Errorf("tencentcloud: delete record failed: %w", err) } diff --git a/providers/dns/tencentcloud/tencentcloud.toml b/providers/dns/tencentcloud/tencentcloud.toml index beb138e91..50f4ee9d5 100644 --- a/providers/dns/tencentcloud/tencentcloud.toml +++ b/providers/dns/tencentcloud/tencentcloud.toml @@ -1,13 +1,13 @@ Name = "Tencent Cloud DNS" Description = '''''' -URL = "https://cloud.tencent.com/product/cns" +URL = "https://cloud.tencent.com/product/dns" Code = "tencentcloud" Since = "v4.6.0" Example = ''' TENCENTCLOUD_SECRET_ID=abcdefghijklmnopqrstuvwx \ TENCENTCLOUD_SECRET_KEY=your-secret-key \ -lego --email you@example.com --dns tencentcloud -d '*.example.com' -d example.com run +lego --dns tencentcloud -d '*.example.com' -d example.com run ''' [Configuration] @@ -17,10 +17,10 @@ lego --email you@example.com --dns tencentcloud -d '*.example.com' -d example.co [Configuration.Additional] TENCENTCLOUD_SESSION_TOKEN = "Access Key token" TENCENTCLOUD_REGION = "Region" - TENCENTCLOUD_POLLING_INTERVAL = "Time between DNS propagation check" - TENCENTCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - TENCENTCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge" - TENCENTCLOUD_HTTP_TIMEOUT = "API request timeout" + TENCENTCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + TENCENTCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + TENCENTCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" + TENCENTCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://cloud.tencent.com/document/product/1427/56153" diff --git a/providers/dns/tencentcloud/tencentcloud_test.go b/providers/dns/tencentcloud/tencentcloud_test.go index c5a2fd974..ce6358174 100644 --- a/providers/dns/tencentcloud/tencentcloud_test.go +++ b/providers/dns/tencentcloud/tencentcloud_test.go @@ -55,6 +55,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -127,6 +128,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -140,6 +142,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/tencentcloud/wrapper.go b/providers/dns/tencentcloud/wrapper.go index 32b66d523..6a66bc1c6 100644 --- a/providers/dns/tencentcloud/wrapper.go +++ b/providers/dns/tencentcloud/wrapper.go @@ -1,23 +1,24 @@ package tencentcloud import ( + "context" "errors" "fmt" "github.com/go-acme/lego/v4/challenge/dns01" + dnspod "github.com/go-acme/tencentclouddnspod/v20210323" "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" errorsdk "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/errors" - dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323" "golang.org/x/net/idna" ) -func (d *DNSProvider) getHostedZone(domain string) (*dnspod.DomainListItem, error) { +func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (*dnspod.DomainListItem, error) { request := dnspod.NewDescribeDomainListRequest() var domains []*dnspod.DomainListItem for { - response, err := d.client.DescribeDomainList(request) + response, err := dnspod.DescribeDomainListWithContext(ctx, d.client, request) if err != nil { return nil, fmt.Errorf("API call failed: %w", err) } @@ -37,6 +38,7 @@ func (d *DNSProvider) getHostedZone(domain string) (*dnspod.DomainListItem, erro } var hostedZone *dnspod.DomainListItem + for _, zone := range domains { unfqdn := dns01.UnFqdn(authZone) if *zone.Name == unfqdn || *zone.Punycode == unfqdn { @@ -51,7 +53,7 @@ func (d *DNSProvider) getHostedZone(domain string) (*dnspod.DomainListItem, erro return hostedZone, nil } -func (d *DNSProvider) findTxtRecords(zone *dnspod.DomainListItem, fqdn string) ([]*dnspod.RecordListItem, error) { +func (d *DNSProvider) findTxtRecords(ctx context.Context, zone *dnspod.DomainListItem, fqdn string) ([]*dnspod.RecordListItem, error) { recordName, err := extractRecordName(fqdn, *zone.Name) if err != nil { return nil, err @@ -64,7 +66,7 @@ func (d *DNSProvider) findTxtRecords(zone *dnspod.DomainListItem, fqdn string) ( request.RecordType = common.StringPtr("TXT") request.RecordLine = common.StringPtr("默认") - response, err := d.client.DescribeRecordList(request) + response, err := dnspod.DescribeRecordListWithContext(ctx, d.client, request) if err != nil { var sdkError *errorsdk.TencentCloudSDKError if errors.As(err, &sdkError) { @@ -72,6 +74,7 @@ func (d *DNSProvider) findTxtRecords(zone *dnspod.DomainListItem, fqdn string) ( return nil, nil } } + return nil, err } diff --git a/providers/dns/timewebcloud/internal/client.go b/providers/dns/timewebcloud/internal/client.go index b3030861e..ec3c8703d 100644 --- a/providers/dns/timewebcloud/internal/client.go +++ b/providers/dns/timewebcloud/internal/client.go @@ -49,6 +49,7 @@ func (c *Client) CreateRecord(ctx context.Context, zone string, record DNSRecord } respData := &CreateRecordResponse{} + err = c.do(req, respData) if err != nil { return nil, err @@ -127,6 +128,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response ErrorResponse + err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/timewebcloud/internal/client_test.go b/providers/dns/timewebcloud/internal/client_test.go index 5bfa97fa0..9d16ba4c5 100644 --- a/providers/dns/timewebcloud/internal/client_test.go +++ b/providers/dns/timewebcloud/internal/client_test.go @@ -1,87 +1,35 @@ package internal import ( - "bytes" - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) - client.baseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func checkAuthorizationHeader(req *http.Request) error { - val := req.Header.Get("Authorization") - if val != "Bearer secret" { - return fmt.Errorf("invalid header value, got: %s want %s", val, "Bearer secret") - } - return nil -} - -func writeResponse(rw http.ResponseWriter, statusCode int, filename string) error { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - return err - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - return err - } - - return nil + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer secret"), + ) } func TestClient_CreateRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("POST /v1/domains/example.com/dns-records", func(rw http.ResponseWriter, req *http.Request) { - err := checkAuthorizationHeader(req) - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - content, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if string(bytes.TrimSpace(content)) != `{"type":"TXT","value":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","subdomain":"_acme-challenge"}` { - http.Error(rw, "invalid request body: "+string(content), http.StatusBadRequest) - return - } - - err = writeResponse(rw, http.StatusOK, "createDomainDNSRecord.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /v1/domains/example.com/dns-records", + servermock.ResponseFromFixture("createDomainDNSRecord.json"), + servermock.CheckRequestJSONBody(`{"type":"TXT","value":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","subdomain":"_acme-challenge"}`)). + Build(t) payload := DNSRecord{ Type: "TXT", @@ -89,7 +37,7 @@ func TestClient_CreateRecord(t *testing.T) { SubDomain: "_acme-challenge", } - response, err := client.CreateRecord(context.Background(), "example.com.", payload) + response, err := client.CreateRecord(t.Context(), "example.com.", payload) require.NoError(t, err) expected := &DNSRecord{ @@ -101,51 +49,37 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_CreateRecord_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("POST /v1/domains/example.com/dns-records", + servermock.ResponseFromFixture("error_bad_request.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) - mux.HandleFunc("POST /v1/domains/example.com/dns-records", func(rw http.ResponseWriter, _ *http.Request) { - err := writeResponse(rw, http.StatusBadRequest, "error_bad_request.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - _, err := client.CreateRecord(context.Background(), "example.com.", DNSRecord{}) + _, err := client.CreateRecord(t.Context(), "example.com.", DNSRecord{}) require.Error(t, err) assert.EqualError(t, err, "400: Value must be a number conforming to the specified constraints (bad_request) [15095f25-aac3-4d60-a788-96cb5136f186]") } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /v1/domains/example.com/dns-records/123", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) - mux.HandleFunc("DELETE /v1/domains/example.com/dns-records/123", func(rw http.ResponseWriter, req *http.Request) { - err := checkAuthorizationHeader(req) - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - rw.WriteHeader(http.StatusNoContent) - }) - - err := client.DeleteRecord(context.Background(), "example.com.", 123) + err := client.DeleteRecord(t.Context(), "example.com.", 123) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /v1/domains/example.com/dns-records/123", + servermock.ResponseFromFixture("error_unauthorized.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) - mux.HandleFunc("DELETE /v1/domains/example.com/dns-records/123", func(rw http.ResponseWriter, _ *http.Request) { - err := writeResponse(rw, http.StatusBadRequest, "error_unauthorized.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - err := client.DeleteRecord(context.Background(), "example.com.", 123) + err := client.DeleteRecord(t.Context(), "example.com.", 123) require.Error(t, err) assert.EqualError(t, err, "401: Unauthorized (unauthorized) [15095f25-aac3-4d60-a788-96cb5136f186]") diff --git a/providers/dns/timewebcloud/internal/types.go b/providers/dns/timewebcloud/internal/types.go index 81da4df5c..80cdb2c70 100644 --- a/providers/dns/timewebcloud/internal/types.go +++ b/providers/dns/timewebcloud/internal/types.go @@ -3,9 +3,11 @@ package internal import "fmt" type DNSRecord struct { - ID int `json:"id,omitempty"` - Type string `json:"type,omitempty"` - Value string `json:"value,omitempty"` + ID int `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Value string `json:"value,omitempty"` + + // SubDomain is the full name of a subdomain (not only the subdomain label). SubDomain string `json:"subdomain,omitempty"` } diff --git a/providers/dns/timewebcloud/timewebcloud.go b/providers/dns/timewebcloud/timewebcloud.go index 18e4cf91f..a599566e3 100644 --- a/providers/dns/timewebcloud/timewebcloud.go +++ b/providers/dns/timewebcloud/timewebcloud.go @@ -9,8 +9,10 @@ import ( "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/timewebcloud/internal" ) @@ -25,6 +27,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { AuthToken string @@ -78,7 +82,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("timewebcloud: authentication token is missing") } - client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken)) + client := internal.NewClient( + clientdebug.Wrap( + internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken), + ), + ) return &DNSProvider{ config: config, @@ -102,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) @@ -137,6 +140,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("timewebcloud: unknown record ID for '%s'", info.EffectiveFQDN) } diff --git a/providers/dns/timewebcloud/timewebcloud.toml b/providers/dns/timewebcloud/timewebcloud.toml index 4f8d7e860..c8bde636a 100644 --- a/providers/dns/timewebcloud/timewebcloud.toml +++ b/providers/dns/timewebcloud/timewebcloud.toml @@ -6,16 +6,16 @@ Since = "v4.20.0" Example = ''' TIMEWEBCLOUD_AUTH_TOKEN=xxxxxx \ -lego --email you@example.com --dns timewebcloud -d '*.example.com' -d example.com run +lego --dns timewebcloud -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] TIMEWEBCLOUD_AUTH_TOKEN = "Authentication token" [Configuration.Additional] - TIMEWEBCLOUD_POLLING_INTERVAL = "Time between DNS propagation check" - TIMEWEBCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - TIMEWEBCLOUD_HTTP_TIMEOUT = "API request timeout" + TIMEWEBCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + TIMEWEBCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + TIMEWEBCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://timeweb.cloud/api-docs" diff --git a/providers/dns/timewebcloud/timewebcloud_test.go b/providers/dns/timewebcloud/timewebcloud_test.go index cd3e2e26f..26e107578 100644 --- a/providers/dns/timewebcloud/timewebcloud_test.go +++ b/providers/dns/timewebcloud/timewebcloud_test.go @@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -97,6 +98,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -110,6 +112,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/todaynic/internal/client.go b/providers/dns/todaynic/internal/client.go new file mode 100644 index 000000000..2c537f4a7 --- /dev/null +++ b/providers/dns/todaynic/internal/client.go @@ -0,0 +1,141 @@ +package internal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" + querystring "github.com/google/go-querystring/query" +) + +const defaultBaseURL = "https://todapi.now.cn:2443" + +// Client the TodayNIC API client. +type Client struct { + authUserID string + apiKey string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(authUserID, apiKey string) (*Client, error) { + if authUserID == "" || apiKey == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + authUserID: authUserID, + apiKey: apiKey, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) AddRecord(ctx context.Context, record Record) (int, error) { + endpoint := c.BaseURL.JoinPath("api", "dns", "add-domain-record.json") + + query, err := querystring.Values(record) + if err != nil { + return 0, err + } + + req, err := c.newRequest(ctx, endpoint, query) + if err != nil { + return 0, err + } + + var result APIResponse + + err = c.do(req, &result) + if err != nil { + return 0, err + } + + return result.ID, nil +} + +func (c *Client) DeleteRecord(ctx context.Context, recordID int) error { + endpoint := c.BaseURL.JoinPath("api", "dns", "delete-domain-record.json") + + query := endpoint.Query() + query.Set("Id", strconv.Itoa(recordID)) + + req, err := c.newRequest(ctx, endpoint, query) + if err != nil { + return err + } + + return c.do(req, nil) +} + +func (c *Client) do(req *http.Request, result any) error { + useragent.SetHeader(req.Header) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func (c *Client) newRequest(ctx context.Context, endpoint *url.URL, query url.Values) (*http.Request, error) { + query.Set("auth-userid", c.authUserID) + query.Set("api-key", c.apiKey) + + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/todaynic/internal/client_test.go b/providers/dns/todaynic/internal/client_test.go new file mode 100644 index 000000000..71ee7f8b7 --- /dev/null +++ b/providers/dns/todaynic/internal/client_test.go @@ -0,0 +1,94 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("user123", "secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + ) +} + +func TestClient_AddRecord(t *testing.T) { + client := mockBuilder(). + Route("GET /api/dns/add-domain-record.json", + servermock.ResponseFromFixture("add_record.json"), + servermock.CheckQueryParameter().Strict(). + With("Domain", "example.com"). + With("Host", "_acme-challenge"). + With("Type", "TXT"). + With("Value", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"). + With("Ttl", "600"). + With("auth-userid", "user123"). + With("api-key", "secret"), + ). + Build(t) + + record := Record{ + Domain: "example.com", + Host: "_acme-challenge", + Type: "TXT", + Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: "600", + } + + recordID, err := client.AddRecord(t.Context(), record) + require.NoError(t, err) + + assert.Equal(t, 11554102, recordID) +} + +func TestClient_AddRecord_error(t *testing.T) { + client := mockBuilder(). + Route("GET /api/dns/add-domain-record.json", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusNotFound), + ). + Build(t) + + record := Record{ + Domain: "example.com", + Host: "_acme-challenge", + Type: "TXT", + Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: "600", + } + + _, err := client.AddRecord(t.Context(), record) + require.EqualError(t, err, "host.repeat (2d5876b2-f272-43e9-acc1-4c6a3d3683b1)") +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("GET /api/dns/delete-domain-record.json", + servermock.ResponseFromFixture("add_record.json"), + servermock.CheckQueryParameter().Strict(). + With("Id", "123"). + With("auth-userid", "user123"). + With("api-key", "secret"), + ). + Build(t) + + err := client.DeleteRecord(t.Context(), 123) + require.NoError(t, err) +} diff --git a/providers/dns/todaynic/internal/fixtures/add_record.json b/providers/dns/todaynic/internal/fixtures/add_record.json new file mode 100644 index 000000000..27f34d71c --- /dev/null +++ b/providers/dns/todaynic/internal/fixtures/add_record.json @@ -0,0 +1,4 @@ +{ + "RequestId": "f60ea4d9-67ef-49fa-bbae-06178a6e7293", + "Id": 11554102 +} diff --git a/providers/dns/todaynic/internal/fixtures/error.json b/providers/dns/todaynic/internal/fixtures/error.json new file mode 100644 index 000000000..3ea9c9310 --- /dev/null +++ b/providers/dns/todaynic/internal/fixtures/error.json @@ -0,0 +1,4 @@ +{ + "RequestId": "2d5876b2-f272-43e9-acc1-4c6a3d3683b1", + "error": "host.repeat" +} diff --git a/providers/dns/todaynic/internal/types.go b/providers/dns/todaynic/internal/types.go new file mode 100644 index 000000000..0a15c7da8 --- /dev/null +++ b/providers/dns/todaynic/internal/types.go @@ -0,0 +1,26 @@ +package internal + +import "fmt" + +type APIError struct { + RequestID string `json:"RequestId"` + Message string `json:"error"` +} + +func (a *APIError) Error() string { + return fmt.Sprintf("%s (%s)", a.Message, a.RequestID) +} + +type Record struct { + Domain string `url:"Domain,omitempty"` + Host string `url:"Host,omitempty"` + Type string `url:"Type,omitempty"` + Value string `url:"Value,omitempty"` + Mx string `url:"Mx,omitempty"` + TTL string `url:"Ttl,omitempty"` +} + +type APIResponse struct { + RequestID string `json:"RequestId"` + ID int `json:"Id"` +} diff --git a/providers/dns/todaynic/todaynic.go b/providers/dns/todaynic/todaynic.go new file mode 100644 index 000000000..3a3734033 --- /dev/null +++ b/providers/dns/todaynic/todaynic.go @@ -0,0 +1,164 @@ +// Package todaynic implements a DNS provider for solving the DNS-01 challenge using TodayNIC. +package todaynic + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/todaynic/internal" +) + +// Environment variables names. +const ( + envNamespace = "TODAYNIC_" + + EnvAuthUserID = envNamespace + "AUTH_USER_ID" + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + AuthUserID string + APIKey string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, 600), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + recordIDs map[string]int + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for TodayNIC. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAuthUserID, EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("todaynic: %w", err) + } + + config := NewDefaultConfig() + config.AuthUserID = values[EnvAuthUserID] + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for TodayNIC. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("todaynic: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.AuthUserID, config.APIKey) + if err != nil { + return nil, fmt.Errorf("todaynic: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]int), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("todaynic: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("todaynic: %w", err) + } + + record := internal.Record{ + Domain: dns01.UnFqdn(authZone), + Host: subDomain, + Type: "TXT", + Value: info.Value, + TTL: strconv.Itoa(d.config.TTL), + } + + recordID, err := d.client.AddRecord(context.Background(), record) + if err != nil { + return fmt.Errorf("todaynic: add record: %w", err) + } + + d.recordIDsMu.Lock() + d.recordIDs[token] = recordID + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + + if !ok { + return fmt.Errorf("todaynic: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + err := d.client.DeleteRecord(context.Background(), recordID) + if err != nil { + return fmt.Errorf("todaynic: delete record: %w", err) + } + + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/todaynic/todaynic.toml b/providers/dns/todaynic/todaynic.toml new file mode 100644 index 000000000..16d55ccc0 --- /dev/null +++ b/providers/dns/todaynic/todaynic.toml @@ -0,0 +1,25 @@ +Name = "TodayNIC/时代互联" +Description = '''''' +URL = "https://www.todaynic.com/" +Code = "todaynic" +Since = "v4.32.0" + +Example = ''' +TODAYNIC_AUTH_USER_ID="xxx" \ +TODAYNIC_API_KEY="yyy" \ +lego --dns todaynic -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + TODAYNIC_AUTH_USER_ID = "account ID" + TODAYNIC_API_KEY = "API key" + [Configuration.Additional] + TODAYNIC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + TODAYNIC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + TODAYNIC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" + TODAYNIC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://www.todaynic.com/partner/mode_Http_Api_detail.php" + apipost = "https://docs.apipost.net/docs/detail/49dcef10a876000?target_id=0" diff --git a/providers/dns/todaynic/todaynic_test.go b/providers/dns/todaynic/todaynic_test.go new file mode 100644 index 000000000..c73bf6cc5 --- /dev/null +++ b/providers/dns/todaynic/todaynic_test.go @@ -0,0 +1,207 @@ +package todaynic + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAuthUserID, EnvAPIKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAuthUserID: "user123", + EnvAPIKey: "secret", + }, + }, + { + desc: "missing user ID", + envVars: map[string]string{ + EnvAuthUserID: "", + EnvAPIKey: "secret", + }, + expected: "todaynic: some credentials information are missing: TODAYNIC_AUTH_USER_ID", + }, + { + desc: "missing API key", + envVars: map[string]string{ + EnvAuthUserID: "user123", + EnvAPIKey: "", + }, + expected: "todaynic: some credentials information are missing: TODAYNIC_API_KEY", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "todaynic: some credentials information are missing: TODAYNIC_AUTH_USER_ID,TODAYNIC_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + authUserID string + apiKey string + expected string + }{ + { + desc: "success", + authUserID: "user123", + apiKey: "secret", + }, + { + desc: "missing user ID", + apiKey: "secret", + expected: "todaynic: credentials missing", + }, + { + desc: "missing API key", + authUserID: "user123", + expected: "todaynic: credentials missing", + }, + { + desc: "missing credentials", + expected: "todaynic: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.AuthUserID = test.authUserID + config.APIKey = test.apiKey + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.AuthUserID = "user123" + config.APIKey = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("GET /api/dns/add-domain-record.json", + servermock.ResponseFromInternal("add_record.json"), + servermock.CheckQueryParameter().Strict(). + With("Domain", "example.com"). + With("Host", "_acme-challenge"). + With("Type", "TXT"). + With("Value", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"). + With("Ttl", "600"). + With("auth-userid", "user123"). + With("api-key", "secret"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("GET /api/dns/delete-domain-record.json", + servermock.ResponseFromInternal("add_record.json"), + servermock.CheckQueryParameter().Strict(). + With("Id", "123"). + With("auth-userid", "user123"). + With("api-key", "secret"), + ). + Build(t) + + provider.recordIDs["abc"] = 123 + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/transip/transip.go b/providers/dns/transip/transip.go index a3b18d862..bc2913aa4 100644 --- a/providers/dns/transip/transip.go +++ b/providers/dns/transip/transip.go @@ -4,8 +4,10 @@ package transip 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/transip/gotransip/v6" @@ -22,8 +24,11 @@ const ( EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { AccountName string @@ -31,6 +36,7 @@ type Config struct { PropagationTimeout time.Duration PollingInterval time.Duration TTL int64 + HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. @@ -39,6 +45,9 @@ func NewDefaultConfig() *Config { TTL: int64(env.GetOrDefaultInt(EnvTTL, 10)), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, } } @@ -70,10 +79,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("transip: the configuration of the DNS provider is nil") } - client, err := gotransip.NewClient(gotransip.ClientConfiguration{ + cfg := gotransip.ClientConfiguration{ AccountName: config.AccountName, PrivateKeyPath: config.PrivateKeyPath, - }) + } + + if config.HTTPClient != nil { + cfg.HTTPClient = config.HTTPClient + } else { + // Uses an explicit default HTTP client because the desec.NewDefaultClientOptions uses the http.DefaultClient. + cfg.HTTPClient = &http.Client{Timeout: 30 * time.Second} + } + + client, err := gotransip.NewClient(cfg) if err != nil { return nil, fmt.Errorf("transip: %w", err) } @@ -150,6 +168,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { if err = d.repository.RemoveDNSEntry(domainName, entry); err != nil { return fmt.Errorf("transip: couldn't get Record ID in CleanUp: %w", err) } + return nil } } diff --git a/providers/dns/transip/transip.toml b/providers/dns/transip/transip.toml index 47059c551..bf7d58ee3 100644 --- a/providers/dns/transip/transip.toml +++ b/providers/dns/transip/transip.toml @@ -7,7 +7,7 @@ Since = "v2.0.0" Example = ''' TRANSIP_ACCOUNT_NAME = "Account name" \ TRANSIP_PRIVATE_KEY_PATH = "transip.key" \ -lego --email you@example.com --dns transip -d '*.example.com' -d example.com run +lego --dns transip -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,9 +15,10 @@ lego --email you@example.com --dns transip -d '*.example.com' -d example.com run TRANSIP_ACCOUNT_NAME = "Account name" TRANSIP_PRIVATE_KEY_PATH = "Private key path" [Configuration.Additional] - TRANSIP_POLLING_INTERVAL = "Time between DNS propagation check" - TRANSIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - TRANSIP_TTL = "The TTL of the TXT record used for the DNS challenge" + TRANSIP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + TRANSIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)" + TRANSIP_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)" + TRANSIP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.transip.eu/rest/docs.html" diff --git a/providers/dns/transip/transip_test.go b/providers/dns/transip/transip_test.go index b42753680..3c6e86657 100644 --- a/providers/dns/transip/transip_test.go +++ b/providers/dns/transip/transip_test.go @@ -58,6 +58,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -79,6 +80,7 @@ func TestNewDNSProvider(t *testing.T) { // Therefore, we test if the error type is the same. t.Run("could not open private key path", func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(map[string]string{ @@ -156,6 +158,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -169,6 +172,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/ultradns/ultradns.go b/providers/dns/ultradns/ultradns.go index 369af4567..da76c56f4 100644 --- a/providers/dns/ultradns/ultradns.go +++ b/providers/dns/ultradns/ultradns.go @@ -4,8 +4,10 @@ package ultradns 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/useragent" @@ -29,6 +31,8 @@ const ( const defaultEndpoint = "https://api.ultradns.com/" +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config @@ -50,7 +54,7 @@ type Config struct { func NewDefaultConfig() *Config { return &Config{ Endpoint: env.GetOrDefaultString(EnvEndpoint, defaultEndpoint), - TTL: env.GetOrDefaultInt(EnvTTL, 120), + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second), } @@ -118,7 +122,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { RecordType: "TXT", } - res, _, _ := recordService.Read(rrSetKeyData) + resp, _, _ := recordService.Read(rrSetKeyData) rrSetData := &rrset.RRSet{ OwnerName: info.EffectiveFQDN, @@ -127,11 +131,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { RData: []string{info.Value}, } - if res != nil && res.StatusCode == 200 { + if resp != nil && resp.StatusCode == http.StatusOK { _, err = recordService.Update(rrSetKeyData, rrSetData) } else { _, err = recordService.Create(rrSetKeyData, rrSetData) } + if err != nil { return fmt.Errorf("ultradns: %w", err) } diff --git a/providers/dns/ultradns/ultradns.toml b/providers/dns/ultradns/ultradns.toml index c6ff72eac..4c3dbbe72 100644 --- a/providers/dns/ultradns/ultradns.toml +++ b/providers/dns/ultradns/ultradns.toml @@ -7,7 +7,7 @@ Since = "v4.10.0" Example = ''' ULTRADNS_USERNAME=username \ ULTRADNS_PASSWORD=password \ -lego --email you@example.com --dns ultradns -d '*.example.com' -d example.com run +lego --dns ultradns -d '*.example.com' -d example.com run ''' [Configuration] @@ -16,9 +16,9 @@ lego --email you@example.com --dns ultradns -d '*.example.com' -d example.com ru ULTRADNS_PASSWORD = "API Password" [Configuration.Additional] ULTRADNS_ENDPOINT = "API endpoint URL, defaults to https://api.ultradns.com/" - ULTRADNS_TTL = "The TTL of the TXT record used for the DNS challenge" - ULTRADNS_POLLING_INTERVAL = "Time between DNS propagation check" - ULTRADNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + ULTRADNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + ULTRADNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)" + ULTRADNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" [Links] API = "https://ultra-portalstatic.ultradns.com/static/docs/REST-API_User_Guide.pdf" diff --git a/providers/dns/ultradns/ultradns_test.go b/providers/dns/ultradns/ultradns_test.go index eefa63ec3..464bc51cd 100644 --- a/providers/dns/ultradns/ultradns_test.go +++ b/providers/dns/ultradns/ultradns_test.go @@ -177,6 +177,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/uniteddomains/uniteddomains.go b/providers/dns/uniteddomains/uniteddomains.go new file mode 100644 index 000000000..683cab1fe --- /dev/null +++ b/providers/dns/uniteddomains/uniteddomains.go @@ -0,0 +1,105 @@ +// Package uniteddomains implements a DNS provider for solving the DNS-01 challenge using United-Domains. +package uniteddomains + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/ionos" +) + +// Environment variables names. +const ( + envNamespace = "UNITEDDOMAINS_" + + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +const defaultBaseURL = "https://dnsapi.united-domains.de/dns" + +const minTTL = 300 + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config = ionos.Config + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, minTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 15*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + prv challenge.ProviderTimeout +} + +// NewDNSProvider returns a DNSProvider instance configured for United-Domains. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("uniteddomains: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for United-Domains. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("uniteddomains: the configuration of the DNS provider is nil") + } + + provider, err := ionos.NewDNSProviderConfig(config, defaultBaseURL) + if err != nil { + return nil, fmt.Errorf("uniteddomains: %w", err) + } + + return &DNSProvider{prv: provider}, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.prv.Timeout() +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + err := d.prv.Present(domain, token, keyAuth) + if err != nil { + return fmt.Errorf("uniteddomains: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + err := d.prv.CleanUp(domain, token, keyAuth) + if err != nil { + return fmt.Errorf("uniteddomains: %w", err) + } + + return nil +} diff --git a/providers/dns/uniteddomains/uniteddomains.toml b/providers/dns/uniteddomains/uniteddomains.toml new file mode 100644 index 000000000..fe8b9e574 --- /dev/null +++ b/providers/dns/uniteddomains/uniteddomains.toml @@ -0,0 +1,22 @@ +Name = "United-Domains" +Description = '''''' +URL = "https://www.united-domains.de/" +Code = "uniteddomains" +Since = "v4.29.0" + +Example = ''' +UNITEDDOMAINS_API_KEY=xxxxxxxx \ +lego --dns uniteddomains -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + UNITEDDOMAINS_API_KEY = "API key `.` https://www.united-domains.de/help/faq-article/getting-started-with-the-united-domains-dns-api/" + [Configuration.Additional] + UNITEDDOMAINS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + UNITEDDOMAINS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 900)" + UNITEDDOMAINS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + UNITEDDOMAINS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://www.united-domains.de/dns-apidoc/" diff --git a/providers/dns/uniteddomains/uniteddomains_test.go b/providers/dns/uniteddomains/uniteddomains_test.go new file mode 100644 index 000000000..93afb01ab --- /dev/null +++ b/providers/dns/uniteddomains/uniteddomains_test.go @@ -0,0 +1,126 @@ +package uniteddomains + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "123", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvAPIKey: "", + }, + expected: "uniteddomains: some credentials information are missing: UNITEDDOMAINS_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.prv) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + tll int + expected string + }{ + { + desc: "success", + apiKey: "123", + tll: minTTL, + }, + { + desc: "missing credentials", + tll: minTTL, + expected: "uniteddomains: credentials missing", + }, + { + desc: "invalid TTL", + apiKey: "123", + tll: 30, + expected: "uniteddomains: invalid TTL, TTL (30) must be greater than 300", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + config.TTL = test.tll + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.prv) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/variomedia/internal/client.go b/providers/dns/variomedia/internal/client.go index 4a671e88e..0e4ef9518 100644 --- a/providers/dns/variomedia/internal/client.go +++ b/providers/dns/variomedia/internal/client.go @@ -38,7 +38,7 @@ func NewClient(apiToken string) *Client { // CreateDNSRecord creates a new DNS entry. // https://api.variomedia.de/docs/dns-records.html#erstellen -func (c Client) CreateDNSRecord(ctx context.Context, record DNSRecord) (*CreateDNSRecordResponse, error) { +func (c *Client) CreateDNSRecord(ctx context.Context, record DNSRecord) (*CreateDNSRecordResponse, error) { endpoint := c.baseURL.JoinPath("dns-records") data := CreateDNSRecordRequest{Data: Data{ @@ -52,6 +52,7 @@ func (c Client) CreateDNSRecord(ctx context.Context, record DNSRecord) (*CreateD } var result CreateDNSRecordResponse + err = c.do(req, &result) if err != nil { return nil, err @@ -62,7 +63,7 @@ func (c Client) CreateDNSRecord(ctx context.Context, record DNSRecord) (*CreateD // DeleteDNSRecord deletes a DNS record. // https://api.variomedia.de/docs/dns-records.html#l%C3%B6schen -func (c Client) DeleteDNSRecord(ctx context.Context, id string) (*DeleteRecordResponse, error) { +func (c *Client) DeleteDNSRecord(ctx context.Context, id string) (*DeleteRecordResponse, error) { endpoint := c.baseURL.JoinPath("dns-records", id) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) @@ -71,6 +72,7 @@ func (c Client) DeleteDNSRecord(ctx context.Context, id string) (*DeleteRecordRe } var result DeleteRecordResponse + err = c.do(req, &result) if err != nil { return nil, err @@ -81,7 +83,7 @@ func (c Client) DeleteDNSRecord(ctx context.Context, id string) (*DeleteRecordRe // GetJob returns a single job based on its ID. // https://api.variomedia.de/docs/job-queue.html -func (c Client) GetJob(ctx context.Context, id string) (*GetJobResponse, error) { +func (c *Client) GetJob(ctx context.Context, id string) (*GetJobResponse, error) { endpoint := c.baseURL.JoinPath("queue-jobs", id) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) @@ -90,6 +92,7 @@ func (c Client) GetJob(ctx context.Context, id string) (*GetJobResponse, error) } var result GetJobResponse + err = c.do(req, &result) if err != nil { return nil, err @@ -98,7 +101,7 @@ func (c Client) GetJob(ctx context.Context, id string) (*GetJobResponse, error) return &result, nil } -func (c Client) do(req *http.Request, data any) error { +func (c *Client) do(req *http.Request, data any) error { req.Header.Set(authorizationHeader, "token "+c.apiToken) resp, err := c.HTTPClient.Do(req) @@ -153,6 +156,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError + err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/variomedia/internal/client_test.go b/providers/dns/variomedia/internal/client_test.go index c0017f24a..24778bdaf 100644 --- a/providers/dns/variomedia/internal/client_test.go +++ b/providers/dns/variomedia/internal/client_test.go @@ -1,68 +1,37 @@ package internal import ( - "context" - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("secret") - client.baseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func mockHandler(method string, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("invalid method, got %s want %s", req.Method, method), http.StatusBadRequest) - return - } - - filename = "./fixtures/" + filename - statusCode := http.StatusOK - - if req.Header.Get(authorizationHeader) != "token secret" { - statusCode = http.StatusUnauthorized - filename = "./fixtures/error.json" - } - - rw.WriteHeader(statusCode) - - file, err := os.Open(filename) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } + return client, nil + }, + servermock.CheckHeader(). + WithAccept("application/vnd.variomedia.v1+json"). + WithAuthorization("token secret")) } func TestClient_CreateDNSRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/dns-records", mockHandler(http.MethodPost, "POST_dns-records.json")) + client := mockBuilder(). + Route("POST /dns-records", + servermock.ResponseFromFixture("POST_dns-records.json"), + servermock.CheckHeader(). + WithContentType("application/vnd.api+json"), + servermock.CheckRequestJSONBody(`{"data":{"type":"dns-record","attributes":{"record_type":"TXT","name":"_acme-challenge","domain":"example.com","data":"test","ttl":300}}}`)). + Build(t) record := DNSRecord{ RecordType: "TXT", @@ -72,7 +41,7 @@ func TestClient_CreateDNSRecord(t *testing.T) { TTL: 300, } - resp, err := client.CreateDNSRecord(context.Background(), record) + resp, err := client.CreateDNSRecord(t.Context(), record) require.NoError(t, err) expected := &CreateDNSRecordResponse{ @@ -108,11 +77,12 @@ func TestClient_CreateDNSRecord(t *testing.T) { } func TestClient_DeleteDNSRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /dns-records/test", + servermock.ResponseFromFixture("DELETE_dns-records_pending.json")). + Build(t) - mux.HandleFunc("/dns-records/test", mockHandler(http.MethodDelete, "DELETE_dns-records_pending.json")) - - resp, err := client.DeleteDNSRecord(context.Background(), "test") + resp, err := client.DeleteDNSRecord(t.Context(), "test") require.NoError(t, err) expected := &DeleteRecordResponse{ @@ -143,11 +113,12 @@ func TestClient_DeleteDNSRecord(t *testing.T) { } func TestClient_GetJob(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /queue-jobs/test", + servermock.ResponseFromFixture("GET_queue-jobs.json")). + Build(t) - mux.HandleFunc("/queue-jobs/test", mockHandler(http.MethodGet, "GET_queue-jobs.json")) - - resp, err := client.GetJob(context.Background(), "test") + resp, err := client.GetJob(t.Context(), "test") require.NoError(t, err) expected := &GetJobResponse{ diff --git a/providers/dns/variomedia/variomedia.go b/providers/dns/variomedia/variomedia.go index 4a7d0e901..2d12fd975 100644 --- a/providers/dns/variomedia/variomedia.go +++ b/providers/dns/variomedia/variomedia.go @@ -10,10 +10,13 @@ import ( "sync" "time" + "github.com/cenkalti/backoff/v5" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/wait" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/variomedia/internal" ) @@ -30,6 +33,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIToken string @@ -88,6 +93,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ config: config, client: client, @@ -158,6 +165,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("variomedia: unknown record ID for '%s'", info.EffectiveFQDN) } @@ -172,18 +180,30 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("variomedia: %w", err) } + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + return nil } -func (d *DNSProvider) waitJob(ctx context.Context, domain string, id string) error { - return wait.For("variomedia: apply change on "+domain, d.config.PropagationTimeout, d.config.PollingInterval, func() (bool, error) { - result, err := d.client.GetJob(ctx, id) - if err != nil { - return false, err - } +func (d *DNSProvider) waitJob(ctx context.Context, domain, id string) error { + return wait.Retry(ctx, + func() error { + result, err := d.client.GetJob(ctx, id) + if err != nil { + return fmt.Errorf("apply change on %s: %w", domain, err) + } - log.Infof("variomedia: [%s] %s: %s %s", domain, result.Data.ID, result.Data.Attributes.JobType, result.Data.Attributes.Status) + log.Infof("variomedia: [%s] %s: %s %s", domain, result.Data.ID, result.Data.Attributes.JobType, result.Data.Attributes.Status) - return result.Data.Attributes.Status == "done", nil - }) + if result.Data.Attributes.Status != "done" { + return fmt.Errorf("apply change on %s: status: %s", domain, result.Data.Attributes.Status) + } + + return nil + }, + backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)), + backoff.WithMaxElapsedTime(d.config.PropagationTimeout), + ) } diff --git a/providers/dns/variomedia/variomedia.toml b/providers/dns/variomedia/variomedia.toml index 945a6f9f5..8390d1922 100644 --- a/providers/dns/variomedia/variomedia.toml +++ b/providers/dns/variomedia/variomedia.toml @@ -6,18 +6,18 @@ Since = "v4.8.0" Example = ''' VARIOMEDIA_API_TOKEN=xxxx \ -lego --email you@example.com --dns variomedia -d '*.example.com' -d example.com run +lego --dns variomedia -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] VARIOMEDIA_API_TOKEN = "API token" [Configuration.Additional] - VARIOMEDIA_POLLING_INTERVAL = "Time between DNS propagation check" - VARIOMEDIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - VARIOMEDIA_TTL = "The TTL of the TXT record used for the DNS challenge" - VARIOMEDIA_SEQUENCE_INTERVAL = "Time between sequential requests" - VARIOMEDIA_HTTP_TIMEOUT = "API request timeout" + VARIOMEDIA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + VARIOMEDIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + VARIOMEDIA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + VARIOMEDIA_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" + VARIOMEDIA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.variomedia.de/docs/dns-records.html" diff --git a/providers/dns/variomedia/variomedia_test.go b/providers/dns/variomedia/variomedia_test.go index 305646070..552419fd0 100644 --- a/providers/dns/variomedia/variomedia_test.go +++ b/providers/dns/variomedia/variomedia_test.go @@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -91,6 +92,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -104,6 +106,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/vegadns/fixtures/create_record.json b/providers/dns/vegadns/fixtures/create_record.json new file mode 100644 index 000000000..2199130b9 --- /dev/null +++ b/providers/dns/vegadns/fixtures/create_record.json @@ -0,0 +1,12 @@ +{ + "status": "ok", + "record": { + "name": "_acme-challenge.example.com", + "value": "my_challenge", + "record_type": "TXT", + "ttl": 3600, + "record_id": 3, + "location_id": null, + "domain_id": 1 + } +} diff --git a/providers/dns/vegadns/fixtures/record_delete.json b/providers/dns/vegadns/fixtures/record_delete.json new file mode 100644 index 000000000..bc4e01029 --- /dev/null +++ b/providers/dns/vegadns/fixtures/record_delete.json @@ -0,0 +1,3 @@ +{ + "status": "ok" +} diff --git a/providers/dns/vegadns/fixtures/records.json b/providers/dns/vegadns/fixtures/records.json new file mode 100644 index 000000000..9fa41ce7a --- /dev/null +++ b/providers/dns/vegadns/fixtures/records.json @@ -0,0 +1,43 @@ +{ + "status": "ok", + "total_records": 2, + "domain": { + "status": "active", + "domain": "example.com", + "owner_id": 0, + "domain_id": 1 + }, + "records": [ + { + "retry": "2048", + "minimum": "2560", + "refresh": "16384", + "email": "hostmaster.example.com", + "record_type": "SOA", + "expire": "1048576", + "ttl": 86400, + "record_id": 1, + "nameserver": "ns1.example.com", + "domain_id": 1, + "serial": "" + }, + { + "name": "example.com", + "value": "ns1.example.com", + "record_type": "NS", + "ttl": 3600, + "record_id": 2, + "location_id": null, + "domain_id": 1 + }, + { + "name": "_acme-challenge.example.com", + "value": "my_challenge", + "record_type": "TXT", + "ttl": 3600, + "record_id": 3, + "location_id": null, + "domain_id": 1 + } + ] +} diff --git a/providers/dns/vegadns/fixtures/token.json b/providers/dns/vegadns/fixtures/token.json new file mode 100644 index 000000000..39ab1a4a9 --- /dev/null +++ b/providers/dns/vegadns/fixtures/token.json @@ -0,0 +1,5 @@ +{ + "access_token": "699dd4ff-e381-46b8-8bf8-5de49dd56c1f", + "token_type": "bearer", + "expires_in": 3600 +} diff --git a/providers/dns/vegadns/vegadns.go b/providers/dns/vegadns/vegadns.go index 0da11ef31..9f1f189c3 100644 --- a/providers/dns/vegadns/vegadns.go +++ b/providers/dns/vegadns/vegadns.go @@ -2,13 +2,17 @@ package vegadns import ( + "context" "errors" "fmt" + "net/http" "time" - vegaClient "github.com/OpenDNS/vegadns2client" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/nrdcg/vegadns" ) // Environment variables names. @@ -22,16 +26,21 @@ const ( EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { - BaseURL string - APIKey string - APISecret string + BaseURL string + APIKey string + APISecret string + PropagationTimeout time.Duration PollingInterval time.Duration TTL int + HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. @@ -39,14 +48,17 @@ func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 10), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 12*time.Minute), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 1*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, time.Minute), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config - client vegaClient.VegaDNSClient + client *vegadns.Client } // NewDNSProvider returns a DNSProvider instance configured for VegaDNS. @@ -72,11 +84,21 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("vegadns: the configuration of the DNS provider is nil") } - vega := vegaClient.NewVegaDNSClient(config.BaseURL) - vega.APIKey = config.APIKey - vega.APISecret = config.APISecret + if config.HTTPClient == nil { + config.HTTPClient = &http.Client{Timeout: 30 * time.Second} + } - return &DNSProvider{client: vega, config: config}, nil + config.HTTPClient = clientdebug.Wrap(config.HTTPClient) + + client, err := vegadns.NewClient(config.BaseURL, + vegadns.WithOAuth(config.APIKey, config.APISecret), + vegadns.WithHTTPClient(config.HTTPClient), + ) + if err != nil { + return nil, fmt.Errorf("vegadns: %w", err) + } + + return &DNSProvider{client: client, config: config}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. @@ -87,39 +109,71 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) - _, domainID, err := d.client.GetAuthZone(info.EffectiveFQDN) + domainID, err := d.findDomainID(ctx, info.EffectiveFQDN) if err != nil { - return fmt.Errorf("vegadns: can't find Authoritative Zone for %s in Present: %w", info.EffectiveFQDN, err) + return fmt.Errorf("vegadns: find domain ID for %s: %w", info.EffectiveFQDN, err) } - err = d.client.CreateTXT(domainID, info.EffectiveFQDN, info.Value, d.config.TTL) + err = d.client.CreateTXTRecord(ctx, domainID, dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL) if err != nil { - return fmt.Errorf("vegadns: %w", err) + return fmt.Errorf("vegadns: create TXT record: %w", err) } + return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) - _, domainID, err := d.client.GetAuthZone(info.EffectiveFQDN) + domainID, err := d.findDomainID(ctx, info.EffectiveFQDN) if err != nil { - return fmt.Errorf("vegadns: can't find Authoritative Zone for %s in CleanUp: %w", info.EffectiveFQDN, err) + return fmt.Errorf("vegadns: find domain ID for %s: %w", info.EffectiveFQDN, err) } - txt := dns01.UnFqdn(info.EffectiveFQDN) - - recordID, err := d.client.GetRecordID(domainID, txt, "TXT") + recordID, err := d.findRecordID(ctx, domainID, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { - return fmt.Errorf("vegadns: couldn't get Record ID in CleanUp: %w", err) + return fmt.Errorf("vegadns: find record ID for %d: %w", domainID, err) } - err = d.client.DeleteRecord(recordID) + err = d.client.DeleteRecord(ctx, recordID) if err != nil { - return fmt.Errorf("vegadns: %w", err) + return fmt.Errorf("vegadns: delete record: %w", err) } + return nil } + +func (d *DNSProvider) findDomainID(ctx context.Context, fqdn string) (int, error) { + for host := range dns01.UnFqdnDomainsSeq(fqdn) { + id, err := d.client.GetDomainID(ctx, host) + if err != nil { + continue + } + + return id, nil + } + + return 0, errors.New("domain not found") +} + +func (d *DNSProvider) findRecordID(ctx context.Context, domainID int, name string) (int, error) { + records, err := d.client.GetRecords(ctx, domainID) + if err != nil { + return 0, fmt.Errorf("get records: %w", err) + } + + for _, r := range records { + if r.Name == name && r.RecordType == "TXT" { + return r.RecordID, nil + } + } + + return 0, errors.New("record not found") +} diff --git a/providers/dns/vegadns/vegadns.toml b/providers/dns/vegadns/vegadns.toml index e1a7cc713..d01490f55 100644 --- a/providers/dns/vegadns/vegadns.toml +++ b/providers/dns/vegadns/vegadns.toml @@ -12,9 +12,9 @@ Example = '''''' SECRET_VEGADNS_SECRET = "API secret" VEGADNS_URL = "API endpoint URL" [Configuration.Additional] - VEGADNS_POLLING_INTERVAL = "Time between DNS propagation check" - VEGADNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - VEGADNS_TTL = "The TTL of the TXT record used for the DNS challenge" + VEGADNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 60)" + VEGADNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 720)" + VEGADNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)" [Links] API = "https://github.com/shupp/VegaDNS-API" diff --git a/providers/dns/vegadns/vegadns_mock_test.go b/providers/dns/vegadns/vegadns_mock_test.go deleted file mode 100644 index 5a705e092..000000000 --- a/providers/dns/vegadns/vegadns_mock_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package vegadns - -const tokenResponseMock = ` -{ - "access_token":"699dd4ff-e381-46b8-8bf8-5de49dd56c1f", - "token_type":"bearer", - "expires_in":3600 -} -` - -const domainsResponseMock = ` -{ - "domains":[ - { - "domain_id":1, - "domain":"example.com", - "status":"active", - "owner_id":0 - } - ] -} -` - -const recordsResponseMock = ` -{ - "status":"ok", - "total_records":2, - "domain":{ - "status":"active", - "domain":"example.com", - "owner_id":0, - "domain_id":1 - }, - "records":[ - { - "retry":"2048", - "minimum":"2560", - "refresh":"16384", - "email":"hostmaster.example.com", - "record_type":"SOA", - "expire":"1048576", - "ttl":86400, - "record_id":1, - "nameserver":"ns1.example.com", - "domain_id":1, - "serial":"" - }, - { - "name":"example.com", - "value":"ns1.example.com", - "record_type":"NS", - "ttl":3600, - "record_id":2, - "location_id":null, - "domain_id":1 - }, - { - "name":"_acme-challenge.example.com", - "value":"my_challenge", - "record_type":"TXT", - "ttl":3600, - "record_id":3, - "location_id":null, - "domain_id":1 - } - ] -} -` - -const recordCreatedResponseMock = ` -{ - "status":"ok", - "record":{ - "name":"_acme-challenge.example.com", - "value":"my_challenge", - "record_type":"TXT", - "ttl":3600, - "record_id":3, - "location_id":null, - "domain_id":1 - } -} -` - -const recordDeletedResponseMock = `{"status": "ok"}` diff --git a/providers/dns/vegadns/vegadns_test.go b/providers/dns/vegadns/vegadns_test.go index 60f614c3b..edcd2c60d 100644 --- a/providers/dns/vegadns/vegadns_test.go +++ b/providers/dns/vegadns/vegadns_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -18,6 +19,7 @@ var envTest = tester.NewEnvTest(EnvKey, EnvSecret, EnvURL) func TestNewDNSProvider_Fail(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() _, err := NewDNSProvider() @@ -26,12 +28,10 @@ func TestNewDNSProvider_Fail(t *testing.T) { func TestDNSProvider_TimeoutSuccess(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() - setupTest(t, muxSuccess()) - - provider, err := NewDNSProvider() - require.NoError(t, err) + provider := mockBuilder().Build(t) timeout, interval := provider.Timeout() assert.Equal(t, 12*time.Minute, timeout) @@ -42,35 +42,51 @@ func TestDNSProvider_Present(t *testing.T) { testCases := []struct { desc string handler http.Handler + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "Success", - handler: muxSuccess(), + desc: "success", + builder: mockBuilder(). + Route("POST /1.0/token", + servermock.ResponseFromFixture("token.json")). + Route("GET /1.0/domains", getDomainHandler()). + Route("POST /1.0/records", + servermock.ResponseFromFixture("create_record.json"). + WithStatusCode(http.StatusCreated)), }, { - desc: "FailToFindZone", - handler: muxFailToFindZone(), - expectedError: "vegadns: can't find Authoritative Zone for _acme-challenge.example.com. in Present: Unable to find auth zone for fqdn _acme-challenge.example.com", + desc: "fail to find the zone", + builder: mockBuilder(). + Route("POST /1.0/token", + servermock.ResponseFromFixture("token.json")). + Route("GET /1.0/domains", + servermock.Noop(). + WithStatusCode(http.StatusNotFound)), + expectedError: "vegadns: find domain ID for _acme-challenge.example.com.: domain not found", }, { - desc: "FailToCreateTXT", - handler: muxFailToCreateTXT(), - expectedError: "vegadns: Got bad answer from VegaDNS on CreateTXT. Code: 400. Message: ", + desc: "fail to create TXT record", + builder: mockBuilder(). + Route("POST /1.0/token", + servermock.ResponseFromFixture("token.json")). + Route("GET /1.0/domains", getDomainHandler()). + Route("POST /1.0/records", + servermock.Noop(). + WithStatusCode(http.StatusBadRequest)), + expectedError: "vegadns: create TXT record: bad answer from VegaDNS (code: 400, message: )", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() - setupTest(t, test.handler) + provider := test.builder.Build(t) - provider, err := NewDNSProvider() - require.NoError(t, err) - - err = provider.Present(testDomain, "token", "keyAuth") + err := provider.Present(testDomain, "token", "keyAuth") if test.expectedError == "" { assert.NoError(t, err) } else { @@ -83,36 +99,54 @@ func TestDNSProvider_Present(t *testing.T) { func TestDNSProvider_CleanUp(t *testing.T) { testCases := []struct { desc string - handler http.Handler + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "Success", - handler: muxSuccess(), + desc: "success", + builder: mockBuilder(). + Route("POST /1.0/token", + servermock.ResponseFromFixture("token.json")). + Route("GET /1.0/domains", getDomainHandler()). + Route("GET /1.0/records", + servermock.ResponseFromFixture("records.json"), + servermock.CheckQueryParameter().With("domain_id", "1")). + Route("DELETE /1.0/records/3", + servermock.ResponseFromFixture("record_delete.json")), }, { - desc: "FailToFindZone", - handler: muxFailToFindZone(), - expectedError: "vegadns: can't find Authoritative Zone for _acme-challenge.example.com. in CleanUp: Unable to find auth zone for fqdn _acme-challenge.example.com", + desc: "fail to find the zone", + builder: mockBuilder(). + Route("POST /1.0/token", + servermock.ResponseFromFixture("token.json")). + Route("GET /1.0/domains", + servermock.Noop(). + WithStatusCode(http.StatusNotFound)), + expectedError: "vegadns: find domain ID for _acme-challenge.example.com.: domain not found", }, { - desc: "FailToGetRecordID", - handler: muxFailToGetRecordID(), - expectedError: "vegadns: couldn't get Record ID in CleanUp: Got bad answer from VegaDNS on GetRecordID. Code: 404. Message: ", + desc: "fail to get record ID", + builder: mockBuilder(). + Route("POST /1.0/token", + servermock.ResponseFromFixture("token.json")). + Route("GET /1.0/domains", getDomainHandler()). + Route("GET /1.0/records", + servermock.Noop(). + WithStatusCode(http.StatusNotFound), + servermock.CheckQueryParameter().With("domain_id", "1")), + expectedError: "vegadns: find record ID for 1: get records: bad answer from VegaDNS (code: 404, message: )", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() - setupTest(t, test.handler) + provider := test.builder.Build(t) - provider, err := NewDNSProvider() - require.NoError(t, err) - - err = provider.CleanUp(testDomain, "token", "keyAuth") + err := provider.CleanUp(testDomain, "token", "keyAuth") if test.expectedError == "" { assert.NoError(t, err) } else { @@ -122,163 +156,37 @@ func TestDNSProvider_CleanUp(t *testing.T) { } } -func muxSuccess() *http.ServeMux { - mux := http.NewServeMux() +func getDomainHandler() http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Query().Get("search") == testDomain { + fmt.Fprint(rw, ` +{ + "domains":[ + { + "domain_id":1, + "domain":"example.com", + "status":"active", + "owner_id":0 + } + ] +} +`) - mux.HandleFunc("/1.0/token", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, tokenResponseMock) return } - w.WriteHeader(http.StatusBadRequest) - }) - mux.HandleFunc("/1.0/domains", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("search") == "example.com" { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, domainsResponseMock) - return - } - w.WriteHeader(http.StatusNotFound) - }) - - mux.HandleFunc("/1.0/records", func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - if r.URL.Query().Get("domain_id") == "1" { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, recordsResponseMock) - return - } - w.WriteHeader(http.StatusNotFound) - return - case http.MethodPost: - w.WriteHeader(http.StatusCreated) - fmt.Fprint(w, recordCreatedResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/1.0/records/3", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodDelete { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, recordDeletedResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - fmt.Printf("Not Found for Request: (%+v)\n\n", r) - }) - - return mux + rw.WriteHeader(http.StatusNotFound) + } } -func muxFailToFindZone() *http.ServeMux { - mux := http.NewServeMux() +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + envTest.Apply(map[string]string{ + EnvKey: "key", + EnvSecret: "secret", + EnvURL: server.URL, + }) - mux.HandleFunc("/1.0/token", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, tokenResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/1.0/domains", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - }) - - return mux -} - -func muxFailToCreateTXT() *http.ServeMux { - mux := http.NewServeMux() - - mux.HandleFunc("/1.0/token", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, tokenResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/1.0/domains", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("search") == testDomain { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, domainsResponseMock) - return - } - w.WriteHeader(http.StatusNotFound) - }) - - mux.HandleFunc("/1.0/records", func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - if r.URL.Query().Get("domain_id") == "1" { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, recordsResponseMock) - return - } - w.WriteHeader(http.StatusNotFound) - return - case http.MethodPost: - w.WriteHeader(http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - return mux -} - -func muxFailToGetRecordID() *http.ServeMux { - mux := http.NewServeMux() - - mux.HandleFunc("/1.0/token", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, tokenResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/1.0/domains", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("search") == testDomain { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, domainsResponseMock) - return - } - w.WriteHeader(http.StatusNotFound) - }) - - mux.HandleFunc("/1.0/records", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.WriteHeader(http.StatusNotFound) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - return mux -} - -func setupTest(t *testing.T, mux http.Handler) { - t.Helper() - - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - envTest.Apply(map[string]string{ - EnvKey: "key", - EnvSecret: "secret", - EnvURL: server.URL, + return NewDNSProvider() }) } diff --git a/providers/dns/vercel/internal/client.go b/providers/dns/vercel/internal/client.go index 4bc59ba0c..930f3543e 100644 --- a/providers/dns/vercel/internal/client.go +++ b/providers/dns/vercel/internal/client.go @@ -51,6 +51,7 @@ func (c *Client) CreateRecord(ctx context.Context, zone string, record Record) ( } respData := &CreateRecordResponse{} + err = c.do(req, respData) if err != nil { return nil, err @@ -61,7 +62,7 @@ func (c *Client) CreateRecord(ctx context.Context, zone string, record Record) ( // DeleteRecord deletes a DNS record. // https://vercel.com/docs/rest-api#endpoints/dns/delete-a-dns-record -func (c *Client) DeleteRecord(ctx context.Context, zone string, recordID string) error { +func (c *Client) DeleteRecord(ctx context.Context, zone, recordID string) error { endpoint := c.baseURL.JoinPath("v2", "domains", dns01.UnFqdn(zone), "records", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) @@ -135,6 +136,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response APIErrorResponse + err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/vercel/internal/client_test.go b/providers/dns/vercel/internal/client_test.go index 771349b25..eb5ee501d 100644 --- a/providers/dns/vercel/internal/client_test.go +++ b/providers/dns/vercel/internal/client_test.go @@ -1,72 +1,38 @@ package internal import ( - "bytes" - "context" - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"), "123") + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"), "123") - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer secret")) } func TestClient_CreateRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v2/domains/example.com/records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Bearer secret" { - http.Error(rw, fmt.Sprintf("invalid API token: %s", auth), http.StatusUnauthorized) - return - } - - teamID := req.URL.Query().Get("teamId") - if teamID != "123" { - http.Error(rw, fmt.Sprintf("invalid team ID: %s", teamID), http.StatusUnauthorized) - return - } - - reqBody, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - expectedReqBody := `{"name":"_acme-challenge.example.com.","type":"TXT","value":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":60}` - assert.Equal(t, expectedReqBody, string(bytes.TrimSpace(reqBody))) - - rw.WriteHeader(http.StatusOK) - _, err = fmt.Fprintf(rw, `{ + client := mockBuilder(). + Route("POST /v2/domains/example.com/records", + servermock.RawStringResponse(`{ "uid": "9e2eab60-0ba5-4dff-b481-2999c9764b84", "updated": 1 - }`) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + }`), + servermock.CheckRequestJSONBody(`{"name":"_acme-challenge.example.com.","type":"TXT","value":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":60}`), + servermock.CheckQueryParameter().Strict(). + With("teamId", "123")). + Build(t) record := Record{ Name: "_acme-challenge.example.com.", @@ -75,7 +41,7 @@ func TestClient_CreateRecord(t *testing.T) { TTL: 60, } - resp, err := client.CreateRecord(context.Background(), "example.com.", record) + resp, err := client.CreateRecord(t.Context(), "example.com.", record) require.NoError(t, err) expected := &CreateRecordResponse{ @@ -87,28 +53,12 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("DELETE /v2/domains/example.com/records/1234567", nil, + servermock.CheckQueryParameter().Strict(). + With("teamId", "123")). + Build(t) - mux.HandleFunc("/v2/domains/example.com/records/1234567", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - auth := req.Header.Get("Authorization") - if auth != "Bearer secret" { - http.Error(rw, fmt.Sprintf("invalid API token: %s", auth), http.StatusUnauthorized) - return - } - - teamID := req.URL.Query().Get("teamId") - if teamID != "123" { - http.Error(rw, fmt.Sprintf("invalid team ID: %s", teamID), http.StatusUnauthorized) - return - } - - rw.WriteHeader(http.StatusOK) - }) - - err := client.DeleteRecord(context.Background(), "example.com.", "1234567") + err := client.DeleteRecord(t.Context(), "example.com.", "1234567") require.NoError(t, err) } diff --git a/providers/dns/vercel/vercel.go b/providers/dns/vercel/vercel.go index 491251fe5..965e3de12 100644 --- a/providers/dns/vercel/vercel.go +++ b/providers/dns/vercel/vercel.go @@ -9,8 +9,10 @@ import ( "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/vercel/internal" ) @@ -27,6 +29,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { AuthToken string @@ -41,7 +45,7 @@ type Config struct { func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 60), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Second), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), @@ -83,7 +87,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("vercel: credentials missing") } - client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken), config.TeamID) + client := internal.NewClient( + clientdebug.Wrap( + internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken), + ), + config.TeamID, + ) return &DNSProvider{ config: config, @@ -139,6 +148,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("vercel: unknown record ID for '%s'", info.EffectiveFQDN) } diff --git a/providers/dns/vercel/vercel.toml b/providers/dns/vercel/vercel.toml index 60df41798..4700d6d78 100644 --- a/providers/dns/vercel/vercel.toml +++ b/providers/dns/vercel/vercel.toml @@ -6,7 +6,7 @@ Since = "v4.7.0" Example = ''' VERCEL_API_TOKEN=xxxxxx \ -lego --email you@example.com --dns vercel -d '*.example.com' -d example.com run +lego --dns vercel -d '*.example.com' -d example.com run ''' [Configuration] @@ -14,10 +14,10 @@ lego --email you@example.com --dns vercel -d '*.example.com' -d example.com run VERCEL_API_TOKEN = "Authentication token" [Configuration.Additional] VERCEL_TEAM_ID = "Team ID (ex: team_xxxxxxxxxxxxxxxxxxxxxxxx)" - VERCEL_POLLING_INTERVAL = "Time between DNS propagation check" - VERCEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - VERCEL_TTL = "The TTL of the TXT record used for the DNS challenge" - VERCEL_HTTP_TIMEOUT = "API request timeout" + VERCEL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" + VERCEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + VERCEL_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + VERCEL_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://vercel.com/docs/rest-api#endpoints/dns" diff --git a/providers/dns/vercel/vercel_test.go b/providers/dns/vercel/vercel_test.go index 6c19a4db5..d4cf37904 100644 --- a/providers/dns/vercel/vercel_test.go +++ b/providers/dns/vercel/vercel_test.go @@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -95,6 +96,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -108,6 +110,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/versio/fixtures/error_failToCreateTXT.json b/providers/dns/versio/fixtures/error_failToCreateTXT.json new file mode 100644 index 000000000..1e1784517 --- /dev/null +++ b/providers/dns/versio/fixtures/error_failToCreateTXT.json @@ -0,0 +1,6 @@ +{ + "error": { + "code": 400, + "message": "ProcessError|DNS record invalid type _acme-challenge.example.eu. TST" + } +} diff --git a/providers/dns/versio/fixtures/error_failToFindZone.json b/providers/dns/versio/fixtures/error_failToFindZone.json new file mode 100644 index 000000000..635b2bda1 --- /dev/null +++ b/providers/dns/versio/fixtures/error_failToFindZone.json @@ -0,0 +1,6 @@ +{ + "error": { + "code": 401, + "message": "ObjectDoesNotExist|Domain not found" + } +} diff --git a/providers/dns/versio/fixtures/token.json b/providers/dns/versio/fixtures/token.json new file mode 100644 index 000000000..0dc0dda25 --- /dev/null +++ b/providers/dns/versio/fixtures/token.json @@ -0,0 +1,5 @@ +{ + "access_token":"699dd4ff-e381-46b8-8bf8-5de49dd56c1f", + "token_type":"bearer", + "expires_in":3600 +} diff --git a/providers/dns/versio/internal/client.go b/providers/dns/versio/internal/client.go index 6f70aacd2..6a92cc958 100644 --- a/providers/dns/versio/internal/client.go +++ b/providers/dns/versio/internal/client.go @@ -26,7 +26,7 @@ type Client struct { } // NewClient creates a new Client. -func NewClient(username string, password string) *Client { +func NewClient(username, password string) *Client { baseURL, _ := url.Parse(DefaultBaseURL) return &Client{ @@ -48,6 +48,7 @@ func (c *Client) UpdateDomain(ctx context.Context, domain string, msg *DomainInf } respData := &DomainInfoResponse{} + err = c.do(req, respData) if err != nil { return nil, err @@ -71,6 +72,7 @@ func (c *Client) GetDomain(ctx context.Context, domain string) (*DomainInfoRespo } respData := &DomainInfoResponse{} + err = c.do(req, respData) if err != nil { return nil, err @@ -88,6 +90,7 @@ func (c *Client) do(req *http.Request, result any) error { if resp != nil { defer func() { _ = resp.Body.Close() }() } + if err != nil { return errutils.NewHTTPDoError(req, err) } @@ -140,6 +143,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) response := &ErrorResponse{} + err := json.Unmarshal(raw, response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/versio/internal/client_test.go b/providers/dns/versio/internal/client_test.go index f1015d28a..8dfcb4ff8 100644 --- a/providers/dns/versio/internal/client_test.go +++ b/providers/dns/versio/internal/client_test.go @@ -1,64 +1,38 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, h http.HandlerFunc) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.BaseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, h) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.BaseURL, _ = url.Parse(server.URL) - - return client -} - -func writeFixture(rw http.ResponseWriter, filename string) { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("user", "secret")) } func TestClient_GetDomain(t *testing.T) { - client := setupTest(t, "/domains/example.com", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } + client := mockBuilder(). + Route("GET /domains/example.com", + servermock.ResponseFromFixture("get-domain.json"), + servermock.CheckQueryParameter().Strict(). + With("show_dns_records", "true")). + Build(t) - auth := req.Header.Get("Authorization") - if auth != "Basic dXNlcjpzZWNyZXQ=" { - http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized) - return - } - - writeFixture(rw, "get-domain.json") - }) - - records, err := client.GetDomain(context.Background(), "example.com") + records, err := client.GetDomain(t.Context(), "example.com") require.NoError(t, err) expected := &DomainInfoResponse{DomainInfo: DomainInfo{DNSRecords: []Record{ @@ -80,36 +54,22 @@ func TestClient_GetDomain(t *testing.T) { } func TestClient_GetDomain_error(t *testing.T) { - client := setupTest(t, "/domains/example.com", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } + client := mockBuilder(). + Route("GET /domains/example.com", + servermock.ResponseFromFixture("get-domain-error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - rw.WriteHeader(http.StatusUnauthorized) - - writeFixture(rw, "get-domain-error.json") - }) - - _, err := client.GetDomain(context.Background(), "example.com") + _, err := client.GetDomain(t.Context(), "example.com") require.ErrorAs(t, err, &ErrorMessage{}) } func TestClient_UpdateDomain(t *testing.T) { - client := setupTest(t, "/domains/example.com/update", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Basic dXNlcjpzZWNyZXQ=" { - http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized) - return - } - - writeFixture(rw, "update-domain.json") - }) + client := mockBuilder(). + Route("POST /domains/example.com/update", + servermock.ResponseFromFixture("update-domain.json"), + servermock.CheckRequestJSONBodyFromFixture("update-domain-request.json")). + Build(t) msg := &DomainInfo{DNSRecords: []Record{ {Type: "MX", Name: "example.com", Value: "fallback.axc.eu", Priority: 20, TTL: 3600}, @@ -126,7 +86,7 @@ func TestClient_UpdateDomain(t *testing.T) { {Type: "A", Name: "redirect.example.com", Value: "localhost", Priority: 10, TTL: 14400}, }} - records, err := client.UpdateDomain(context.Background(), "example.com", msg) + records, err := client.UpdateDomain(t.Context(), "example.com", msg) require.NoError(t, err) expected := &DomainInfoResponse{DomainInfo: DomainInfo{DNSRecords: []Record{ @@ -148,16 +108,11 @@ func TestClient_UpdateDomain(t *testing.T) { } func TestClient_UpdateDomain_error(t *testing.T) { - client := setupTest(t, "/domains/example.com/update", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - rw.WriteHeader(http.StatusUnauthorized) - - writeFixture(rw, "update-domain.json") - }) + client := mockBuilder(). + Route("POST /domains/example.com/update", + servermock.ResponseFromFixture("update-domain-error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) msg := &DomainInfo{DNSRecords: []Record{ {Type: "MX", Name: "example.com", Value: "fallback.axc.eu", Priority: 20, TTL: 3600}, @@ -174,6 +129,6 @@ func TestClient_UpdateDomain_error(t *testing.T) { {Type: "A", Name: "redirect.example.com", Value: "localhost", Priority: 10, TTL: 14400}, }} - _, err := client.UpdateDomain(context.Background(), "example.com", msg) + _, err := client.UpdateDomain(t.Context(), "example.com", msg) require.ErrorAs(t, err, &ErrorMessage{}) } diff --git a/providers/dns/versio/internal/fixtures/update-domain-request.json b/providers/dns/versio/internal/fixtures/update-domain-request.json new file mode 100644 index 000000000..f351678fc --- /dev/null +++ b/providers/dns/versio/internal/fixtures/update-domain-request.json @@ -0,0 +1,78 @@ +{ + "dns_records": [ + { + "type": "MX", + "name": "example.com", + "value": "fallback.axc.eu", + "prio": 20, + "ttl": 3600 + }, + { + "type": "TXT", + "name": "example.com", + "value": "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"", + "ttl": 3600 + }, + { + "type": "A", + "name": "example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "A", + "name": "ftp.example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "A", + "name": "localhost.example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "A", + "name": "pop.example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "A", + "name": "smtp.example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "A", + "name": "www.example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "A", + "name": "dev.example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "A", + "name": "_domainkey.domain.com.example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "MX", + "name": "example.com", + "value": "spamfilter2.axc.eu", + "ttl": 3600 + }, + { + "type": "A", + "name": "redirect.example.com", + "value": "localhost", + "prio": 10, + "ttl": 14400 + } + ] +} diff --git a/providers/dns/versio/versio.go b/providers/dns/versio/versio.go index b1310f0bc..05a7263c4 100644 --- a/providers/dns/versio/versio.go +++ b/providers/dns/versio/versio.go @@ -10,8 +10,10 @@ import ( "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/versio/internal" ) @@ -30,6 +32,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL *url.URL @@ -52,7 +56,7 @@ func NewDefaultConfig() *Config { return &Config{ BaseURL: baseURL, TTL: env.GetOrDefaultInt(EnvTTL, 300), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Second), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ @@ -88,9 +92,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("versio: the configuration of the DNS provider is nil") } + if config.Username == "" { return nil, errors.New("versio: the versio username is missing") } + if config.Password == "" { return nil, errors.New("versio: the versio password is missing") } @@ -105,6 +111,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } @@ -152,6 +160,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("versio: %w", err) } + return nil } @@ -179,6 +188,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { // loop through the existing entries and remove the specific record msg := &internal.DomainInfo{} + for _, e := range domains.DomainInfo.DNSRecords { if e.Name != info.EffectiveFQDN { msg.DNSRecords = append(msg.DNSRecords, e) @@ -189,5 +199,6 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("versio: %w", err) } + return nil } diff --git a/providers/dns/versio/versio.toml b/providers/dns/versio/versio.toml index 7fc27ebcd..733947095 100644 --- a/providers/dns/versio/versio.toml +++ b/providers/dns/versio/versio.toml @@ -7,7 +7,7 @@ Since = "v2.7.0" Example = ''' VERSIO_USERNAME= \ VERSIO_PASSWORD= \ -lego --email you@example.com --dns versio -d '*.example.com' -d example.com run +lego --dns versio -d '*.example.com' -d example.com run ''' Additional = ''' @@ -20,11 +20,11 @@ To test with the sandbox environment set ```VERSIO_ENDPOINT=https://www.versio.n VERSIO_PASSWORD = "Basic authentication password" [Configuration.Additional] VERSIO_ENDPOINT = "The endpoint URL of the API Server" - VERSIO_POLLING_INTERVAL = "Time between DNS propagation check" - VERSIO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - VERSIO_HTTP_TIMEOUT = "API request timeout" - VERSIO_SEQUENCE_INTERVAL = "Time between sequential requests, default 60s" - VERSIO_TTL = "The TTL of the TXT record used for the DNS challenge" + VERSIO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" + VERSIO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + VERSIO_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" + VERSIO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + VERSIO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.versio.nl/RESTapidoc/" diff --git a/providers/dns/versio/versio_mock_test.go b/providers/dns/versio/versio_mock_test.go deleted file mode 100644 index 07dc74e83..000000000 --- a/providers/dns/versio/versio_mock_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package versio - -const tokenResponseMock = ` -{ - "access_token":"699dd4ff-e381-46b8-8bf8-5de49dd56c1f", - "token_type":"bearer", - "expires_in":3600 -} -` - -const tokenFailToFindZoneMock = `{"error":{"code":401,"message":"ObjectDoesNotExist|Domain not found"}}` - -const tokenFailToCreateTXTMock = `{"error":{"code":400,"message":"ProcessError|DNS record invalid type _acme-challenge.example.eu. TST"}}` diff --git a/providers/dns/versio/versio_test.go b/providers/dns/versio/versio_test.go index 09040ab4c..563e70d05 100644 --- a/providers/dns/versio/versio_test.go +++ b/providers/dns/versio/versio_test.go @@ -1,14 +1,12 @@ package versio import ( - "fmt" - "io" "net/http" "net/http/httptest" "testing" - "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -56,6 +54,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -125,21 +124,37 @@ func TestNewDNSProviderConfig(t *testing.T) { func TestDNSProvider_Present(t *testing.T) { testCases := []struct { desc string - handler http.Handler + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "Success", - handler: muxSuccess(), + desc: "Success", + builder: mockBuilder(). + Route("GET /domains/example.com", + servermock.ResponseFromFixture("token.json"), + servermock.CheckQueryParameter().Strict(). + With("show_dns_records", "true")). + Route("POST /domains/example.com/update", + servermock.ResponseFromFixture("token.json")), }, { - desc: "FailToFindZone", - handler: muxFailToFindZone(), + desc: "FailToFindZone", + builder: mockBuilder(). + Route("GET /domains/example.com", + servermock.ResponseFromFixture("error_failToFindZone.json"). + WithStatusCode(http.StatusUnauthorized)), expectedError: `versio: [status code: 401] 401: ObjectDoesNotExist|Domain not found`, }, { - desc: "FailToCreateTXT", - handler: muxFailToCreateTXT(), + desc: "FailToCreateTXT", + builder: mockBuilder(). + Route("GET /domains/example.com", + servermock.ResponseFromFixture("token.json"), + servermock.CheckQueryParameter().Strict(). + With("show_dns_records", "true")). + Route("POST /domains/example.com/update", + servermock.ResponseFromFixture("error_failToCreateTXT.json"). + WithStatusCode(http.StatusBadRequest)), expectedError: `versio: [status code: 400] 400: ProcessError|DNS record invalid type _acme-challenge.example.eu. TST`, }, } @@ -147,19 +162,12 @@ func TestDNSProvider_Present(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() - baseURL := setupTest(t, test.handler) + provider := test.builder.Build(t) - envTest.Apply(map[string]string{ - EnvUsername: "me@example.com", - EnvPassword: "secret", - EnvEndpoint: baseURL, - }) - provider, err := NewDNSProvider() - require.NoError(t, err) - - err = provider.Present(testDomain, "token", "keyAuth") + err := provider.Present(testDomain, "token", "keyAuth") if test.expectedError == "" { require.NoError(t, err) } else { @@ -172,16 +180,25 @@ func TestDNSProvider_Present(t *testing.T) { func TestDNSProvider_CleanUp(t *testing.T) { testCases := []struct { desc string - handler http.Handler + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "Success", - handler: muxSuccess(), + desc: "Success", + builder: mockBuilder(). + Route("GET /domains/example.com", + servermock.ResponseFromFixture("token.json"), + servermock.CheckQueryParameter().Strict(). + With("show_dns_records", "true")). + Route("POST /domains/example.com/update", + servermock.ResponseFromFixture("token.json")), }, { - desc: "FailToFindZone", - handler: muxFailToFindZone(), + desc: "FailToFindZone", + builder: mockBuilder(). + Route("GET /domains/example.com", + servermock.ResponseFromFixture("error_failToFindZone.json"). + WithStatusCode(http.StatusUnauthorized)), expectedError: `versio: [status code: 401] 401: ObjectDoesNotExist|Domain not found`, }, } @@ -189,20 +206,12 @@ func TestDNSProvider_CleanUp(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() - baseURL := setupTest(t, test.handler) + provider := test.builder.Build(t) - envTest.Apply(map[string]string{ - EnvUsername: "me@example.com", - EnvPassword: "secret", - EnvEndpoint: baseURL, - }) - - provider, err := NewDNSProvider() - require.NoError(t, err) - - err = provider.CleanUp(testDomain, "token", "keyAuth") + err := provider.CleanUp(testDomain, "token", "keyAuth") if test.expectedError == "" { require.NoError(t, err) } else { @@ -212,91 +221,13 @@ func TestDNSProvider_CleanUp(t *testing.T) { } } -func muxSuccess() *http.ServeMux { - mux := http.NewServeMux() - - mux.HandleFunc("/domains/example.com", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Query().Get("show_dns_records") == "true" { - fmt.Fprint(w, tokenResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/domains/example.com/update", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - fmt.Fprint(w, tokenResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - log.Printf("unexpected request: %+v\n\n", r) - data, _ := io.ReadAll(r.Body) - defer func() { _ = r.Body.Close() }() - log.Println(string(data)) - http.NotFound(w, r) - }) - - return mux -} - -func muxFailToFindZone() *http.ServeMux { - mux := http.NewServeMux() - - mux.HandleFunc("/domains/example.com", func(w http.ResponseWriter, _ *http.Request) { - http.Error(w, tokenFailToFindZoneMock, http.StatusUnauthorized) - }) - - return mux -} - -func muxFailToCreateTXT() *http.ServeMux { - mux := http.NewServeMux() - - mux.HandleFunc("/domains/example.com", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Query().Get("show_dns_records") == "true" { - fmt.Fprint(w, tokenResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/domains/example.com/update", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - http.Error(w, tokenFailToCreateTXTMock, http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - log.Printf("unexpected request: %+v\n\n", r) - data, _ := io.ReadAll(r.Body) - defer func() { _ = r.Body.Close() }() - log.Println(string(data)) - http.NotFound(w, r) - }) - - return mux -} - -func setupTest(t *testing.T, handler http.Handler) string { - t.Helper() - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - return server.URL -} - func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -310,9 +241,29 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + envTest.Apply(map[string]string{ + EnvUsername: "me@example.com", + EnvPassword: "secret", + EnvEndpoint: server.URL, + }) + + provider, err := NewDNSProvider() + if err != nil { + return nil, err + } + + provider.client.HTTPClient = server.Client() + + return provider, nil + }) +} diff --git a/providers/dns/vinyldns/mock_test.go b/providers/dns/vinyldns/mock_test.go deleted file mode 100644 index 54fd8e214..000000000 --- a/providers/dns/vinyldns/mock_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package vinyldns - -import ( - "fmt" - "net/http" - "net/http/httptest" - "os" - "sync" - "testing" - - "github.com/stretchr/testify/require" -) - -func setupTest(t *testing.T) (*http.ServeMux, *DNSProvider) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - config := NewDefaultConfig() - config.AccessKey = "foo" - config.SecretKey = "bar" - config.Host = server.URL - - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - return mux, p -} - -type mockRouter struct { - debug bool - - mu sync.Mutex - routes map[string]map[string]http.HandlerFunc -} - -func newMockRouter() *mockRouter { - routes := map[string]map[string]http.HandlerFunc{ - http.MethodGet: {}, - http.MethodPost: {}, - http.MethodPut: {}, - http.MethodDelete: {}, - } - - return &mockRouter{ - routes: routes, - } -} - -func (h *mockRouter) Debug() *mockRouter { - h.debug = true - - return h -} - -func (h *mockRouter) Get(path string, statusCode int, filename string) *mockRouter { - h.add(http.MethodGet, path, statusCode, filename) - return h -} - -func (h *mockRouter) Post(path string, statusCode int, filename string) *mockRouter { - h.add(http.MethodPost, path, statusCode, filename) - return h -} - -func (h *mockRouter) Put(path string, statusCode int, filename string) *mockRouter { - h.add(http.MethodPut, path, statusCode, filename) - return h -} - -func (h *mockRouter) Delete(path string, statusCode int, filename string) *mockRouter { - h.add(http.MethodDelete, path, statusCode, filename) - return h -} - -func (h *mockRouter) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - h.mu.Lock() - defer h.mu.Unlock() - - if h.debug { - fmt.Println(req) - } - - rt := h.routes[req.Method] - if rt == nil { - http.NotFound(rw, req) - return - } - - hdl := rt[req.URL.Path] - if hdl == nil { - http.NotFound(rw, req) - return - } - - hdl(rw, req) -} - -func (h *mockRouter) add(method, path string, statusCode int, filename string) { - h.routes[method][path] = func(rw http.ResponseWriter, req *http.Request) { - rw.WriteHeader(statusCode) - - data, err := os.ReadFile(fmt.Sprintf("./fixtures/%s.json", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - rw.Header().Set("Content-Type", "application/json") - _, _ = rw.Write(data) - } -} diff --git a/providers/dns/vinyldns/vinyldns.go b/providers/dns/vinyldns/vinyldns.go index f5f0d233f..65a024513 100644 --- a/providers/dns/vinyldns/vinyldns.go +++ b/providers/dns/vinyldns/vinyldns.go @@ -2,12 +2,17 @@ package vinyldns import ( + "context" "errors" "fmt" + "net/http" + "strconv" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" "github.com/vinyldns/go-vinyldns/vinyldns" ) @@ -16,23 +21,30 @@ import ( const ( envNamespace = "VINYLDNS_" - EnvAccessKey = envNamespace + "ACCESS_KEY" - EnvSecretKey = envNamespace + "SECRET_KEY" - EnvHost = envNamespace + "HOST" + EnvAccessKey = envNamespace + "ACCESS_KEY" + EnvSecretKey = envNamespace + "SECRET_KEY" + EnvHost = envNamespace + "HOST" + EnvQuoteValue = envNamespace + "QUOTE_VALUE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { - AccessKey string - SecretKey string - Host string + AccessKey string + SecretKey string + Host string + QuoteValue bool + TTL int PropagationTimeout time.Duration PollingInterval time.Duration + HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. @@ -41,6 +53,9 @@ func NewDefaultConfig() *Config { TTL: env.GetOrDefaultInt(EnvTTL, 30), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, } } @@ -63,6 +78,7 @@ func NewDNSProvider() (*DNSProvider, error) { config.AccessKey = values[EnvAccessKey] config.SecretKey = values[EnvSecretKey] config.Host = values[EnvHost] + config.QuoteValue = env.GetOrDefaultBool(EnvQuoteValue, false) return NewDNSProviderConfig(config) } @@ -88,13 +104,22 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { UserAgent: useragent.Get(), }) - client.HTTPClient.Timeout = 30 * time.Second + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } else { + // For compatibility, it should be removed in v5. + client.HTTPClient.Timeout = 30 * time.Second + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) existingRecord, err := d.getRecordSet(info.EffectiveFQDN) @@ -102,10 +127,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("vinyldns: %w", err) } - record := vinyldns.Record{Text: info.Value} + value := d.formatValue(info.Value) + + record := vinyldns.Record{Text: value} if existingRecord == nil || existingRecord.ID == "" { - err = d.createRecordSet(info.EffectiveFQDN, []vinyldns.Record{record}) + err = d.createRecordSet(ctx, info.EffectiveFQDN, []vinyldns.Record{record}) if err != nil { return fmt.Errorf("vinyldns: %w", err) } @@ -114,7 +141,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { } for _, i := range existingRecord.Records { - if i.Text == info.Value { + if i.Text == value { return nil } } @@ -122,7 +149,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { records := existingRecord.Records records = append(records, record) - err = d.updateRecordSet(existingRecord, records) + err = d.updateRecordSet(ctx, existingRecord, records) if err != nil { return fmt.Errorf("vinyldns: %w", err) } @@ -132,6 +159,8 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) existingRecord, err := d.getRecordSet(info.EffectiveFQDN) @@ -143,15 +172,18 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } + value := d.formatValue(info.Value) + var records []vinyldns.Record + for _, i := range existingRecord.Records { - if i.Text != info.Value { + if i.Text != value { records = append(records, i) } } if len(records) == 0 { - err = d.deleteRecordSet(existingRecord) + err = d.deleteRecordSet(ctx, existingRecord) if err != nil { return fmt.Errorf("vinyldns: %w", err) } @@ -159,7 +191,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } - err = d.updateRecordSet(existingRecord, records) + err = d.updateRecordSet(ctx, existingRecord, records) if err != nil { return fmt.Errorf("vinyldns: %w", err) } @@ -172,3 +204,11 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } + +func (d *DNSProvider) formatValue(v string) string { + if d.config.QuoteValue { + return strconv.Quote(v) + } + + return v +} diff --git a/providers/dns/vinyldns/vinyldns.toml b/providers/dns/vinyldns/vinyldns.toml index bdd07bae8..d6dd5810e 100644 --- a/providers/dns/vinyldns/vinyldns.toml +++ b/providers/dns/vinyldns/vinyldns.toml @@ -8,7 +8,7 @@ Example = ''' VINYLDNS_ACCESS_KEY=xxxxxx \ VINYLDNS_SECRET_KEY=yyyyy \ VINYLDNS_HOST=https://api.vinyldns.example.org:9443 \ -lego --email you@example.com --dns vinyldns -d '*.example.com' -d example.com run +lego --dns vinyldns -d '*.example.com' -d example.com run ''' Additional = ''' @@ -22,9 +22,11 @@ Users are required to have DELETE ACL level or zone admin permissions on the Vin VINYLDNS_SECRET_KEY = "The VinylDNS API Secret key" VINYLDNS_HOST = "The VinylDNS API URL" [Configuration.Additional] - VINYLDNS_POLLING_INTERVAL = "Time between DNS propagation check" - VINYLDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - VINYLDNS_TTL = "The TTL of the TXT record used for the DNS challenge" + VINYLDNS_QUOTE_VALUE = "Adds quotes around the TXT record value (Default: false)" + VINYLDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)" + VINYLDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + VINYLDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)" + VINYLDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.vinyldns.io/api/" diff --git a/providers/dns/vinyldns/vinyldns_test.go b/providers/dns/vinyldns/vinyldns_test.go index 8bfb192c8..7dfe2c13f 100644 --- a/providers/dns/vinyldns/vinyldns_test.go +++ b/providers/dns/vinyldns/vinyldns_test.go @@ -2,10 +2,12 @@ package vinyldns import ( "net/http" + "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) @@ -76,6 +78,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -154,63 +157,87 @@ func TestNewDNSProviderConfig(t *testing.T) { } } +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.AccessKey = "foo" + config.SecretKey = "bar" + config.Host = server.URL + config.HTTPClient = server.Client() + + return NewDNSProviderConfig(config) + }) +} + func TestDNSProvider_Present(t *testing.T) { testCases := []struct { desc string keyAuth string - handler http.Handler + builder *servermock.Builder[*DNSProvider] }{ { desc: "new record", keyAuth: "123456d==", - handler: newMockRouter(). - Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName"). - Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll-empty"). - Post("/zones/"+zoneID+"/recordsets", http.StatusAccepted, "recordSetUpdate-create"). - Get("/zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, http.StatusOK, "recordSetChange-create"), + builder: mockBuilder(). + Route("GET /zones/name/"+targetRootDomain+".", + servermock.ResponseFromFixture("zoneByName.json")). + Route("GET /zones/"+zoneID+"/recordsets", + servermock.ResponseFromFixture("recordSetsListAll-empty.json")). + Route("POST /zones/"+zoneID+"/recordsets", + servermock.ResponseFromFixture("recordSetUpdate-create.json"). + WithStatusCode(http.StatusAccepted)). + Route("GET /zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, + servermock.ResponseFromFixture("recordSetChange-create.json")), }, { desc: "existing record", keyAuth: "123456d==", - handler: newMockRouter(). - Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName"). - Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll"), + builder: mockBuilder(). + Route("GET /zones/name/"+targetRootDomain+".", + servermock.ResponseFromFixture("zoneByName.json")). + Route("GET /zones/"+zoneID+"/recordsets", + servermock.ResponseFromFixture("recordSetsListAll.json")), }, { desc: "duplicate key", keyAuth: "abc123!!", - handler: newMockRouter(). - Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName"). - Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll"). - Put("/zones/"+zoneID+"/recordsets/"+recordID, http.StatusAccepted, "recordSetUpdate-create"). - Get("/zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, http.StatusOK, "recordSetChange-create"), + builder: mockBuilder(). + Route("GET /zones/name/"+targetRootDomain+".", + servermock.ResponseFromFixture("zoneByName.json")). + Route("GET /zones/"+zoneID+"/recordsets", + servermock.ResponseFromFixture("recordSetsListAll.json")). + Route("PUT /zones/"+zoneID+"/recordsets/"+recordID, + servermock.ResponseFromFixture("recordSetUpdate-create.json"). + WithStatusCode(http.StatusAccepted)). + Route("GET /zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, + servermock.ResponseFromFixture("recordSetChange-create.json")), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - t.Parallel() + provider := test.builder.Build(t) - mux, p := setupTest(t) - mux.Handle("/", test.handler) - - err := p.Present(targetDomain, "token"+test.keyAuth, test.keyAuth) + err := provider.Present(targetDomain, "token"+test.keyAuth, test.keyAuth) require.NoError(t, err) }) } } func TestDNSProvider_CleanUp(t *testing.T) { - mux, p := setupTest(t) + provider := mockBuilder(). + Route("GET /zones/name/"+targetRootDomain+".", + servermock.ResponseFromFixture("zoneByName.json")). + Route("GET /zones/"+zoneID+"/recordsets", + servermock.ResponseFromFixture("recordSetsListAll.json")). + Route("DELETE /zones/"+zoneID+"/recordsets/"+recordID, + servermock.ResponseFromFixture("recordSetDelete.json"). + WithStatusCode(http.StatusAccepted)). + Route("GET /zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, + servermock.ResponseFromFixture("recordSetChange-delete.json")). + Build(t) - mux.Handle("/", newMockRouter(). - Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName"). - Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll"). - Delete("/zones/"+zoneID+"/recordsets/"+recordID, http.StatusAccepted, "recordSetDelete"). - Get("/zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, http.StatusOK, "recordSetChange-delete"), - ) - - err := p.CleanUp(targetDomain, "123456d==", "123456d==") + err := provider.CleanUp(targetDomain, "123456d==", "123456d==") require.NoError(t, err) } @@ -220,6 +247,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -233,6 +261,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/vinyldns/wrapper.go b/providers/dns/vinyldns/wrapper.go index f17b3de31..e7b59a82b 100644 --- a/providers/dns/vinyldns/wrapper.go +++ b/providers/dns/vinyldns/wrapper.go @@ -1,8 +1,10 @@ package vinyldns import ( + "context" "fmt" + "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/wait" "github.com/vinyldns/go-vinyldns/vinyldns" @@ -25,6 +27,7 @@ func (d *DNSProvider) getRecordSet(fqdn string) (*vinyldns.RecordSet, error) { } var recordSets []vinyldns.RecordSet + for _, i := range allRecordSets { if i.Type == "TXT" { recordSets = append(recordSets, i) @@ -41,7 +44,7 @@ func (d *DNSProvider) getRecordSet(fqdn string) (*vinyldns.RecordSet, error) { } } -func (d *DNSProvider) createRecordSet(fqdn string, records []vinyldns.Record) error { +func (d *DNSProvider) createRecordSet(ctx context.Context, fqdn string, records []vinyldns.Record) error { zoneName, hostName, err := splitDomain(fqdn) if err != nil { return err @@ -65,10 +68,10 @@ func (d *DNSProvider) createRecordSet(fqdn string, records []vinyldns.Record) er return err } - return d.waitForChanges("CreateRS", resp) + return d.waitForChanges(ctx, "CreateRS", resp) } -func (d *DNSProvider) updateRecordSet(recordSet *vinyldns.RecordSet, newRecords []vinyldns.Record) error { +func (d *DNSProvider) updateRecordSet(ctx context.Context, recordSet *vinyldns.RecordSet, newRecords []vinyldns.Record) error { operation := "delete" if len(recordSet.Records) < len(newRecords) { operation = "add" @@ -82,33 +85,35 @@ func (d *DNSProvider) updateRecordSet(recordSet *vinyldns.RecordSet, newRecords return err } - return d.waitForChanges("UpdateRS - "+operation, resp) + return d.waitForChanges(ctx, "UpdateRS - "+operation, resp) } -func (d *DNSProvider) deleteRecordSet(existingRecord *vinyldns.RecordSet) error { +func (d *DNSProvider) deleteRecordSet(ctx context.Context, existingRecord *vinyldns.RecordSet) error { resp, err := d.client.RecordSetDelete(existingRecord.ZoneID, existingRecord.ID) if err != nil { return err } - return d.waitForChanges("DeleteRS", resp) + return d.waitForChanges(ctx, "DeleteRS", resp) } -func (d *DNSProvider) waitForChanges(operation string, resp *vinyldns.RecordSetUpdateResponse) error { - return wait.For("vinyldns", d.config.PropagationTimeout, d.config.PollingInterval, - func() (bool, error) { +func (d *DNSProvider) waitForChanges(ctx context.Context, operation string, resp *vinyldns.RecordSetUpdateResponse) error { + return wait.Retry(ctx, + func() error { change, err := d.client.RecordSetChange(resp.Zone.ID, resp.RecordSet.ID, resp.ChangeID) if err != nil { - return false, fmt.Errorf("failed to query change status: %w", err) + return fmt.Errorf("failed to query change status: %w", err) } - if change.Status == "Complete" { - return true, nil + if change.Status != "Complete" { + return fmt.Errorf("waiting operation: %s, zoneID: %s, recordsetID: %s, changeID: %s", + operation, resp.Zone.ID, resp.RecordSet.ID, resp.ChangeID) } - return false, fmt.Errorf("waiting operation: %s, zoneID: %s, recordsetID: %s, changeID: %s", - operation, resp.Zone.ID, resp.RecordSet.ID, resp.ChangeID) + return nil }, + backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)), + backoff.WithMaxElapsedTime(d.config.PropagationTimeout), ) } diff --git a/providers/dns/virtualname/virtualname.go b/providers/dns/virtualname/virtualname.go new file mode 100644 index 000000000..34637d280 --- /dev/null +++ b/providers/dns/virtualname/virtualname.go @@ -0,0 +1,103 @@ +// Package virtualname implements a DNS provider for solving the DNS-01 challenge using Virtualname DNS. +package virtualname + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/tecnocratica" +) + +// Environment variables names. +const ( + envNamespace = "VIRTUALNAME_" + + EnvToken = envNamespace + "TOKEN" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +const defaultBaseURL = "https://api.virtualname.net/v1" + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config = tecnocratica.Config + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + prv challenge.ProviderTimeout +} + +// NewDNSProvider returns a DNSProvider instance configured for Virtualname. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvToken) + if err != nil { + return nil, fmt.Errorf("virtualname: %w", err) + } + + config := NewDefaultConfig() + config.Token = values[EnvToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Virtualname. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("virtualname: the configuration of the DNS provider is nil") + } + + provider, err := tecnocratica.NewDNSProviderConfig(config, defaultBaseURL) + if err != nil { + return nil, fmt.Errorf("virtualname: %w", err) + } + + return &DNSProvider{prv: provider}, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + err := d.prv.Present(domain, token, keyAuth) + if err != nil { + return fmt.Errorf("virtualname: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + err := d.prv.CleanUp(domain, token, keyAuth) + if err != nil { + return fmt.Errorf("virtualname: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.prv.Timeout() +} diff --git a/providers/dns/virtualname/virtualname.toml b/providers/dns/virtualname/virtualname.toml new file mode 100644 index 000000000..881f09797 --- /dev/null +++ b/providers/dns/virtualname/virtualname.toml @@ -0,0 +1,22 @@ +Name = "Virtualname" +Description = '''''' +URL = "https://www.virtualname.es/" +Code = "virtualname" +Since = "v4.30.0" + +Example = ''' +VIRTUALNAME_TOKEN=xxxxxx \ +lego --dns virtualname -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + VIRTUALNAME_TOKEN = "API token" + [Configuration.Additional] + VIRTUALNAME_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + VIRTUALNAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" + VIRTUALNAME_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + VIRTUALNAME_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://developers.virtualname.net/#dns" diff --git a/providers/dns/virtualname/virtualname_test.go b/providers/dns/virtualname/virtualname_test.go new file mode 100644 index 000000000..da5867e86 --- /dev/null +++ b/providers/dns/virtualname/virtualname_test.go @@ -0,0 +1,116 @@ +package virtualname + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvToken: "secret", + }, + }, + { + desc: "missing credentials: token", + envVars: map[string]string{ + EnvToken: "", + }, + expected: "virtualname: some credentials information are missing: VIRTUALNAME_TOKEN", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.prv) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + token string + expected string + }{ + { + desc: "success", + token: "secret", + }, + { + desc: "missing token", + expected: "virtualname: missing credentials", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Token = test.token + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.prv) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/vkcloud/internal/client.go b/providers/dns/vkcloud/internal/client.go index 5ced88d2d..2b03518db 100644 --- a/providers/dns/vkcloud/internal/client.go +++ b/providers/dns/vkcloud/internal/client.go @@ -46,6 +46,7 @@ func (c *Client) ListZones() ([]DNSZone, error) { endpoint := c.baseURL.JoinPath("/") var zones []DNSZone + opts := &gophercloud.RequestOpts{JSONResponse: &zones} err := c.request(http.MethodGet, endpoint, opts) @@ -60,6 +61,7 @@ func (c *Client) ListTXTRecords(zoneUUID string) ([]DNSTXTRecord, error) { endpoint := c.baseURL.JoinPath(zoneUUID, "txt", "/") var records []DNSTXTRecord + opts := &gophercloud.RequestOpts{JSONResponse: &records} err := c.request(http.MethodGet, endpoint, opts) diff --git a/providers/dns/vkcloud/vkcloud.go b/providers/dns/vkcloud/vkcloud.go index d27feca81..ffacdbe52 100644 --- a/providers/dns/vkcloud/vkcloud.go +++ b/providers/dns/vkcloud/vkcloud.go @@ -6,19 +6,13 @@ import ( "fmt" "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/vkcloud/internal" "github.com/gophercloud/gophercloud" ) -const ( - defaultIdentityEndpoint = "https://infra.mail.ru/identity/v3/" - defaultDNSEndpoint = "https://mcs.mail.ru/public-dns/v2/dns" -) - -const defaultDomainName = "users" - // Environment variables names. const ( envNamespace = "VK_CLOUD_" @@ -37,6 +31,15 @@ const ( EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +const ( + defaultIdentityEndpoint = "https://infra.mail.ru/identity/v3/" + defaultDNSEndpoint = "https://mcs.mail.ru/public-dns/v2/dns" +) + +const defaultDomainName = "users" + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { ProjectID string @@ -116,7 +119,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { } // Present creates a TXT record to fulfill the dns-01 challenge. -func (r *DNSProvider) Present(domain, _, keyAuth string) error { +func (d *DNSProvider) Present(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) @@ -126,12 +129,13 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { authZone = dns01.UnFqdn(authZone) - zones, err := r.client.ListZones() + zones, err := d.client.ListZones() if err != nil { return fmt.Errorf("vkcloud: unable to fetch dns zones: %w", err) } var zoneUUID string + for _, zone := range zones { if zone.Zone == authZone { zoneUUID = zone.UUID @@ -147,7 +151,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { return fmt.Errorf("vkcloud: %w", err) } - err = r.upsertTXTRecord(zoneUUID, subDomain, info.Value) + err = d.upsertTXTRecord(zoneUUID, subDomain, info.Value) if err != nil { return fmt.Errorf("vkcloud: %w", err) } @@ -156,7 +160,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { } // CleanUp removes the TXT record matching the specified parameters. -func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) @@ -166,7 +170,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { authZone = dns01.UnFqdn(authZone) - zones, err := r.client.ListZones() + zones, err := d.client.ListZones() if err != nil { return fmt.Errorf("vkcloud: unable to fetch dns zones: %w", err) } @@ -188,7 +192,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { return fmt.Errorf("vkcloud: %w", err) } - err = r.removeTXTRecord(zoneUUID, subDomain, info.Value) + err = d.removeTXTRecord(zoneUUID, subDomain, info.Value) if err != nil { return fmt.Errorf("vkcloud: %w", err) } @@ -198,12 +202,12 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. -func (r *DNSProvider) Timeout() (timeout, interval time.Duration) { - return r.config.PropagationTimeout, r.config.PollingInterval +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } -func (r *DNSProvider) upsertTXTRecord(zoneUUID, name, value string) error { - records, err := r.client.ListTXTRecords(zoneUUID) +func (d *DNSProvider) upsertTXTRecord(zoneUUID, name, value string) error { + records, err := d.client.ListTXTRecords(zoneUUID) if err != nil { return err } @@ -215,15 +219,15 @@ func (r *DNSProvider) upsertTXTRecord(zoneUUID, name, value string) error { } } - return r.client.CreateTXTRecord(zoneUUID, &internal.DNSTXTRecord{ + return d.client.CreateTXTRecord(zoneUUID, &internal.DNSTXTRecord{ Name: name, Content: value, - TTL: r.config.TTL, + TTL: d.config.TTL, }) } -func (r *DNSProvider) removeTXTRecord(zoneUUID, name, value string) error { - records, err := r.client.ListTXTRecords(zoneUUID) +func (d *DNSProvider) removeTXTRecord(zoneUUID, name, value string) error { + records, err := d.client.ListTXTRecords(zoneUUID) if err != nil { return err } @@ -231,7 +235,7 @@ func (r *DNSProvider) removeTXTRecord(zoneUUID, name, value string) error { name = dns01.UnFqdn(name) for _, record := range records { if record.Name == name && record.Content == value { - return r.client.DeleteTXTRecord(zoneUUID, record.UUID) + return d.client.DeleteTXTRecord(zoneUUID, record.UUID) } } diff --git a/providers/dns/vkcloud/vkcloud.toml b/providers/dns/vkcloud/vkcloud.toml index 8e67e2670..04f57fea3 100644 --- a/providers/dns/vkcloud/vkcloud.toml +++ b/providers/dns/vkcloud/vkcloud.toml @@ -8,7 +8,7 @@ Example = ''' VK_CLOUD_PROJECT_ID="" \ VK_CLOUD_USERNAME="" \ VK_CLOUD_PASSWORD="" \ -lego --email you@example.com --dns vkcloud -d '*.example.com' -d example.com run +lego --dns vkcloud -d '*.example.com' -d example.com run ''' Additional = ''' @@ -33,9 +33,9 @@ You can find all required and additional information on ["Project/Keys" page](ht VK_CLOUD_DNS_ENDPOINT="URL of DNS API. Defaults to https://mcs.mail.ru/public-dns but can be changed for usage with private clouds" VK_CLOUD_IDENTITY_ENDPOINT="URL of OpenStack Auth API, Defaults to https://infra.mail.ru:35357/v3/ but can be changed for usage with private clouds" VK_CLOUD_DOMAIN_NAME="Openstack users domain name. Defaults to `users` but can be changed for usage with private clouds" - VK_CLOUD_POLLING_INTERVAL = "Time between DNS propagation check" - VK_CLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - VK_CLOUD_TTL = "The TTL of the TXT record used for the DNS challenge" + VK_CLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + VK_CLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + VK_CLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" [Links] API = "https://mcs.mail.ru/docs/networks/vnet/networks/publicdns/api" diff --git a/providers/dns/vkcloud/vkcloud_test.go b/providers/dns/vkcloud/vkcloud_test.go index edc32363a..e7883b486 100644 --- a/providers/dns/vkcloud/vkcloud_test.go +++ b/providers/dns/vkcloud/vkcloud_test.go @@ -60,6 +60,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -188,6 +189,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -201,6 +203,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/volcengine/volcengine.go b/providers/dns/volcengine/volcengine.go index 7dc1054d6..765d38adb 100644 --- a/providers/dns/volcengine/volcengine.go +++ b/providers/dns/volcengine/volcengine.go @@ -9,9 +9,10 @@ import ( "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/miekg/dns" + "github.com/go-acme/lego/v4/providers/dns/internal/ptr" "github.com/volcengine/volc-sdk-golang/base" volc "github.com/volcengine/volc-sdk-golang/service/dns" ) @@ -36,6 +37,8 @@ const ( // https://www.volcengine.com/docs/6758/170354 const defaultTTL = 600 +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { AccessKey string @@ -59,7 +62,7 @@ func NewDefaultConfig() *Config { Region: env.GetOrDefaultString(EnvRegion, volc.DefaultRegion), TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 240*time.Second), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 4*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, volc.Timeout*time.Second), } @@ -123,16 +126,16 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("volcengine: get zone ID: %w", err) } - subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, deref(zone.ZoneName)) + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, ptr.Deref(zone.ZoneName)) if err != nil { return fmt.Errorf("volcengine: %w", err) } crr := &volc.CreateRecordRequest{ - Host: pointer(subDomain), - TTL: pointer(int64(d.config.TTL)), - Type: pointer("TXT"), - Value: pointer(info.Value), + Host: ptr.Pointer(subDomain), + TTL: ptr.Pointer(int64(d.config.TTL)), + Type: ptr.Pointer("TXT"), + Value: ptr.Pointer(info.Value), ZID: zone.ZID, } @@ -156,6 +159,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() + if !ok { return fmt.Errorf("volcengine: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } @@ -167,16 +171,18 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("volcengine: delete record: %w", err) } + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + return nil } func (d *DNSProvider) getZone(ctx context.Context, fqdn string) (volc.TopZoneResponse, error) { - for _, index := range dns.Split(fqdn) { - domain := fqdn[index:] - + for domain := range dns01.UnFqdnDomainsSeq(fqdn) { lzr := &volc.ListZonesRequest{ - Key: pointer(dns01.UnFqdn(domain)), - SearchMode: pointer("exact"), + Key: ptr.Pointer(dns01.UnFqdn(domain)), + SearchMode: ptr.Pointer("exact"), } zones, err := d.client.ListZones(ctx, lzr) @@ -184,7 +190,7 @@ func (d *DNSProvider) getZone(ctx context.Context, fqdn string) (volc.TopZoneRes return volc.TopZoneResponse{}, fmt.Errorf("list zones: %w", err) } - total := deref(zones.Total) + total := ptr.Deref(zones.Total) if total == 0 || len(zones.Zones) == 0 { continue @@ -230,14 +236,3 @@ func newClient(config *Config) *volc.Client { return volc.NewClient(caller) } - -func pointer[T any](v T) *T { return &v } - -func deref[T any](v *T) T { - if v == nil { - var zero T - return zero - } - - return *v -} diff --git a/providers/dns/volcengine/volcengine.toml b/providers/dns/volcengine/volcengine.toml index 85431714f..ceedcb18a 100644 --- a/providers/dns/volcengine/volcengine.toml +++ b/providers/dns/volcengine/volcengine.toml @@ -7,7 +7,7 @@ Since = "v4.19.0" Example = ''' VOLC_ACCESSKEY=xxx \ VOLC_SECRETKEY=yyy \ -lego --email you@example.com --dns volcengine -d '*.example.com' -d example.com run +lego --dns volcengine -d '*.example.com' -d example.com run ''' [Configuration] @@ -18,10 +18,10 @@ lego --email you@example.com --dns volcengine -d '*.example.com' -d example.com VOLC_REGION = "Region" VOLC_HOST = "API host" VOLC_SCHEME = "API scheme" - VOLC_POLLING_INTERVAL = "Time between DNS propagation check" - VOLC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - VOLC_TTL = "The TTL of the TXT record used for the DNS challenge" - VOLC_HTTP_TIMEOUT = "API request timeout" + VOLC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + VOLC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 240)" + VOLC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" + VOLC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 15)" [Links] API = "https://www.volcengine.com/docs/6758/155086" diff --git a/providers/dns/volcengine/volcengine_test.go b/providers/dns/volcengine/volcengine_test.go index 5e9167612..0f79ed83a 100644 --- a/providers/dns/volcengine/volcengine_test.go +++ b/providers/dns/volcengine/volcengine_test.go @@ -55,6 +55,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -125,6 +126,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -138,6 +140,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/vscale/vscale.go b/providers/dns/vscale/vscale.go index fa81f58d9..a159db307 100644 --- a/providers/dns/vscale/vscale.go +++ b/providers/dns/vscale/vscale.go @@ -4,20 +4,17 @@ 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/selectel" ) -const minTTL = 60 - // Environment variables names. const ( envNamespace = "VSCALE_" @@ -31,23 +28,20 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const defaultBaseURL = "https://api.vscale.io/v1/domains" + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. -type Config struct { - BaseURL string - Token string - PropagationTimeout time.Duration - PollingInterval time.Duration - TTL int - HTTPClient *http.Client -} +type Config = selectel.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - BaseURL: env.GetOrDefaultString(EnvBaseURL, selectel.DefaultVScaleBaseURL), - TTL: env.GetOrDefaultInt(EnvTTL, minTTL), + BaseURL: env.GetOrDefaultString(EnvBaseURL, defaultBaseURL), + TTL: env.GetOrDefaultInt(EnvTTL, selectel.MinTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, @@ -56,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. @@ -80,53 +73,21 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("vscale: the configuration of the DNS provider is nil") } - if config.Token == "" { - return nil, errors.New("vscale: credentials missing") + if config.BaseURL == "" { + config.BaseURL = defaultBaseURL } - if config.TTL < minTTL { - return nil, fmt.Errorf("vscale: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) - } - - client := selectel.NewClient(config.Token) - if config.HTTPClient != nil { - client.HTTPClient = config.HTTPClient - } - - var err error - client.BaseURL, err = url.Parse(config.BaseURL) + provider, err := selectel.NewDNSProviderConfig(config) if err != nil { return nil, fmt.Errorf("vscale: %w", err) } - return &DNSProvider{config: config, client: client}, nil + return &DNSProvider{prv: provider}, nil } -// Timeout returns the Timeout and interval to use when checking for DNS propagation. -// Adjusting here to cope with spikes in propagation times. -func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.config.PropagationTimeout, d.config.PollingInterval -} - -// Present creates a TXT record to fulfill DNS-01 challenge. +// Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - ctx := context.Background() - - // TODO(ldez) replace domain by FQDN to follow CNAME. - domainObj, err := d.client.GetDomainByName(ctx, domain) - if err != nil { - return fmt.Errorf("vscale: %w", err) - } - - txtRecord := selectel.Record{ - Type: "TXT", - TTL: d.config.TTL, - Name: info.EffectiveFQDN, - Content: info.Value, - } - _, err = d.client.AddRecord(ctx, domainObj.ID, txtRecord) + err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("vscale: %w", err) } @@ -134,35 +95,18 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return nil } -// CleanUp removes a TXT record used for DNS-01 challenge. +// CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - recordName := dns01.UnFqdn(info.EffectiveFQDN) - - ctx := context.Background() - - // TODO(ldez) replace domain by FQDN to follow CNAME. - domainObj, err := d.client.GetDomainByName(ctx, domain) + err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("vscale: %w", err) } - records, err := d.client.ListRecords(ctx, domainObj.ID) - if err != nil { - return fmt.Errorf("vscale: %w", err) - } - - // Delete records with specific FQDN - var lastErr error - for _, record := range records { - if record.Name == recordName { - err = d.client.DeleteRecord(ctx, domainObj.ID, record.ID) - if err != nil { - lastErr = fmt.Errorf("vscale: %w", err) - } - } - } - - return lastErr + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.prv.Timeout() } diff --git a/providers/dns/vscale/vscale.toml b/providers/dns/vscale/vscale.toml index 83aa6a513..f7dc0d943 100644 --- a/providers/dns/vscale/vscale.toml +++ b/providers/dns/vscale/vscale.toml @@ -6,7 +6,7 @@ Since = "v2.0.0" Example = ''' VSCALE_API_TOKEN=xxxxx \ -lego --email you@example.com --dns vscale -d '*.example.com' -d example.com run +lego --dns vscale -d '*.example.com' -d example.com run ''' [Configuration] @@ -14,10 +14,10 @@ lego --email you@example.com --dns vscale -d '*.example.com' -d example.com run VSCALE_API_TOKEN = "API token" [Configuration.Additional] VSCALE_BASE_URL = "API endpoint URL" - VSCALE_POLLING_INTERVAL = "Time between DNS propagation check" - VSCALE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - VSCALE_TTL = "The TTL of the TXT record used for the DNS challenge" - VSCALE_HTTP_TIMEOUT = "API request timeout" + VSCALE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + VSCALE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + VSCALE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + VSCALE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.vscale.io/documentation/api/v1/#api-Domains_Records" diff --git a/providers/dns/vscale/vscale_test.go b/providers/dns/vscale/vscale_test.go index 6a9b25583..9012c7563 100644 --- a/providers/dns/vscale/vscale_test.go +++ b/providers/dns/vscale/vscale_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/providers/dns/internal/selectel" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -36,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -45,8 +47,7 @@ func TestNewDNSProvider(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - assert.NotNil(t, p.config) - assert.NotNil(t, p.client) + assert.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -76,7 +77,7 @@ func TestNewDNSProviderConfig(t *testing.T) { desc: "bad TTL value", token: "123", ttl: 59, - expected: fmt.Sprintf("vscale: invalid TTL, TTL (59) must be greater than %d", minTTL), + expected: fmt.Sprintf("vscale: invalid TTL, TTL (59) must be greater than %d", selectel.MinTTL), }, } @@ -91,8 +92,7 @@ func TestNewDNSProviderConfig(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - assert.NotNil(t, p.config) - assert.NotNil(t, p.client) + assert.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -106,6 +106,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -119,6 +120,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/vultr/vultr.go b/providers/dns/vultr/vultr.go index c6e98709c..f97a321c1 100644 --- a/providers/dns/vultr/vultr.go +++ b/providers/dns/vultr/vultr.go @@ -10,8 +10,10 @@ import ( "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/vultr/govultr/v3" "golang.org/x/oauth2" ) @@ -28,6 +30,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -35,7 +39,7 @@ type Config struct { PollingInterval time.Duration TTL int HTTPClient *http.Client - HTTPTimeout time.Duration + HTTPTimeout time.Duration // TODO(ldez): remove in v5 } // NewDefaultConfig returns a default configuration for the DNSProvider. @@ -81,7 +85,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { authClient := OAuthStaticAccessToken(config.HTTPClient, config.APIKey) authClient.Timeout = config.HTTPTimeout - client := govultr.NewClient(authClient) + client := govultr.NewClient(clientdebug.Wrap(authClient)) return &DNSProvider{client: client, config: config}, nil } @@ -103,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 + `"`, @@ -132,6 +136,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } var allErr []string + for _, rec := range records { err := d.client.DomainRecord.Delete(ctx, zoneDomain, rec.ID) if err != nil { @@ -201,6 +206,7 @@ func (d *DNSProvider) findTxtRecords(ctx context.Context, domain, fqdn string) ( listOptions := &govultr.ListOptions{PerPage: 25} var records []govultr.DomainRecord + for { result, meta, resp, err := d.client.DomainRecord.List(ctx, zoneDomain, listOptions) if err != nil { diff --git a/providers/dns/vultr/vultr.toml b/providers/dns/vultr/vultr.toml index 83b896f77..78e878bea 100644 --- a/providers/dns/vultr/vultr.toml +++ b/providers/dns/vultr/vultr.toml @@ -6,17 +6,17 @@ Since = "v0.3.1" Example = ''' VULTR_API_KEY=xxxxx \ -lego --email you@example.com --dns vultr -d '*.example.com' -d example.com run +lego --dns vultr -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] VULTR_API_KEY = "API key" [Configuration.Additional] - VULTR_POLLING_INTERVAL = "Time between DNS propagation check" - VULTR_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - VULTR_TTL = "The TTL of the TXT record used for the DNS challenge" - VULTR_HTTP_TIMEOUT = "API request timeout" + VULTR_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + VULTR_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + VULTR_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + VULTR_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.vultr.com/api/#dns" diff --git a/providers/dns/vultr/vultr_test.go b/providers/dns/vultr/vultr_test.go index 71d8ad414..17d962b2a 100644 --- a/providers/dns/vultr/vultr_test.go +++ b/providers/dns/vultr/vultr_test.go @@ -1,7 +1,6 @@ package vultr import ( - "context" "encoding/json" "fmt" "net/http" @@ -11,6 +10,7 @@ import ( "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vultr/govultr/v3" @@ -45,6 +45,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -160,56 +161,53 @@ func TestDNSProvider_getHostedZone(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := govultr.NewClient(nil) - err := client.SetBaseURL(server.URL) - require.NoError(t, err) - - p := &DNSProvider{client: client} - var pageCount int - mux.HandleFunc("/v2/domains", func(rw http.ResponseWriter, req *http.Request) { - pageCount++ + provider := servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + client := govultr.NewClient(server.Client()) + err := client.SetBaseURL(server.URL) + require.NoError(t, err) - query := req.URL.Query() - cursor, _ := strconv.Atoi(query.Get("cursor")) - perPage, _ := strconv.Atoi(query.Get("per_page")) + return &DNSProvider{client: client}, nil + }, + ). + Route("GET /v2/domains", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + pageCount++ - var next string - if len(domains)/perPage > cursor { - next = strconv.Itoa(cursor + 1) - } + query := req.URL.Query() + cursor, _ := strconv.Atoi(query.Get("cursor")) + perPage, _ := strconv.Atoi(query.Get("per_page")) - start := cursor * perPage - if len(domains) < start { - start = cursor * len(domains) - } + var next string + if len(domains)/perPage > cursor { + next = strconv.Itoa(cursor + 1) + } - end := (cursor + 1) * perPage - if len(domains) < end { - end = len(domains) - } + start := cursor * perPage + if len(domains) < start { + start = cursor * len(domains) + } - db := domainsBase{ - Domains: domains[start:end], - Meta: &govultr.Meta{ - Total: len(domains), - Links: &govultr.Links{Next: next}, - }, - } + end := min(len(domains), (cursor+1)*perPage) - err = json.NewEncoder(rw).Encode(db) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + db := domainsBase{ + Domains: domains[start:end], + Meta: &govultr.Meta{ + Total: len(domains), + Links: &govultr.Links{Next: next}, + }, + } - zone, err := p.getHostedZone(context.Background(), test.domain) + err := json.NewEncoder(rw).Encode(db) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + })). + Build(t) + + zone, err := provider.getHostedZone(t.Context(), test.domain) require.NoError(t, err) assert.Equal(t, test.expected, zone) @@ -224,6 +222,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -237,6 +236,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/webnames/internal/client.go b/providers/dns/webnames/internal/client.go index 5b1a8b357..985503d2a 100644 --- a/providers/dns/webnames/internal/client.go +++ b/providers/dns/webnames/internal/client.go @@ -83,6 +83,7 @@ func (c *Client) doRequest(ctx context.Context, data url.Values) error { } var r APIResponse + err = json.Unmarshal(raw, &r) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) diff --git a/providers/dns/webnames/internal/client_test.go b/providers/dns/webnames/internal/client_test.go index 8885c50d6..9507b6f98 100644 --- a/providers/dns/webnames/internal/client_test.go +++ b/providers/dns/webnames/internal/client_test.go @@ -1,75 +1,25 @@ package internal import ( - "context" - "fmt" - "io" - "net/http" "net/http/httptest" - "net/url" - "os" - "path" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, filename string, expectedParams url.Values) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.baseURL = server.URL + client.HTTPClient = server.Client() - mux := http.NewServeMux() - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { - http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - err := req.ParseForm() - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - for k, v := range expectedParams { - val := req.PostForm.Get(k) - if len(v) == 0 { - http.Error(rw, fmt.Sprintf("%s: no value", k), http.StatusBadRequest) - return - } - - if val != v[0] { - http.Error(rw, fmt.Sprintf("%s: invalid value: %s != %s", k, val, v[0]), http.StatusBadRequest) - return - } - } - - file, err := os.Open(path.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - server := httptest.NewServer(mux) - - client := NewClient("secret") - client.baseURL = server.URL - client.HTTPClient = server.Client() - - return client + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + ) } func TestClient_AddTXTRecord(t *testing.T) { @@ -94,19 +44,23 @@ func TestClient_AddTXTRecord(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - data := url.Values{} - data.Set("domain", "example.com") - data.Set("type", "TXT") - data.Set("record", "foo:txtTXTtxt") - data.Set("action", "add") - - client := setupTest(t, test.filename, data) + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture(test.filename), + servermock.CheckForm().Strict(). + With("domain", "example.com"). + With("type", "TXT"). + With("record", "foo:txtTXTtxt"). + With("action", "add"). + With("apikey", "secret"), + ). + Build(t) domain := "example.com" subDomain := "foo" content := "txtTXTtxt" - err := client.AddTXTRecord(context.Background(), domain, subDomain, content) + err := client.AddTXTRecord(t.Context(), domain, subDomain, content) test.require(t, err) }) } @@ -134,19 +88,23 @@ func TestClient_RemoveTxtRecord(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - data := url.Values{} - data.Set("domain", "example.com") - data.Set("type", "TXT") - data.Set("record", "foo:txtTXTtxt") - data.Set("action", "delete") - - client := setupTest(t, test.filename, data) + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture(test.filename), + servermock.CheckForm().Strict(). + With("domain", "example.com"). + With("type", "TXT"). + With("record", "foo:txtTXTtxt"). + With("action", "delete"). + With("apikey", "secret"), + ). + Build(t) domain := "example.com" subDomain := "foo" content := "txtTXTtxt" - err := client.RemoveTXTRecord(context.Background(), domain, subDomain, content) + err := client.RemoveTXTRecord(t.Context(), domain, subDomain, content) test.require(t, err) }) } diff --git a/providers/dns/webnames/webnames.go b/providers/dns/webnames/webnames.go index dcc26347e..9c27164e3 100644 --- a/providers/dns/webnames/webnames.go +++ b/providers/dns/webnames/webnames.go @@ -6,16 +6,20 @@ import ( "errors" "fmt" "net/http" + "strings" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/webnames/internal" ) // Environment variables names. const ( - envNamespace = "WEBNAMES_" + envNamespace = "WEBNAMESRU_" + altEnvNamespace = "WEBNAMES_" EnvAPIKey = envNamespace + "API_KEY" @@ -24,6 +28,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string @@ -36,10 +42,10 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + PropagationTimeout: env.GetOneWithFallback(EnvPropagationTimeout, dns01.DefaultPropagationTimeout, env.ParseSecond, altEnvName(EnvPropagationTimeout)), + PollingInterval: env.GetOneWithFallback(EnvPollingInterval, dns01.DefaultPollingInterval, env.ParseSecond, altEnvName(EnvPollingInterval)), HTTPClient: &http.Client{ - Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + Timeout: env.GetOneWithFallback(EnvHTTPTimeout, 20*time.Second, env.ParseSecond, altEnvName(EnvHTTPTimeout)), }, } } @@ -51,11 +57,11 @@ type DNSProvider struct { } // NewDNSProvider returns a new DNS provider using -// environment variable WEBNAMES_API_KEY for adding and removing the DNS record. +// environment variable WEBNAMESRU_API_KEY for adding and removing the DNS record. func NewDNSProvider() (*DNSProvider, error) { - values, err := env.Get(EnvAPIKey) + values, err := env.GetWithFallback([]string{EnvAPIKey, altEnvName(EnvAPIKey)}) if err != nil { - return nil, fmt.Errorf("webnames: %w", err) + return nil, fmt.Errorf("webnamesru: %w", err) } config := NewDefaultConfig() @@ -67,11 +73,11 @@ func NewDNSProvider() (*DNSProvider, error) { // NewDNSProviderConfig return a DNSProvider instance configured for Webnames. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { - return nil, errors.New("webnames: the configuration of the DNS provider is nil") + return nil, errors.New("webnamesru: the configuration of the DNS provider is nil") } if config.APIKey == "" { - return nil, errors.New("webnames: credentials missing") + return nil, errors.New("webnamesru: credentials missing") } client := internal.NewClient(config.APIKey) @@ -80,6 +86,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } @@ -89,17 +97,17 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("webnames: could not find zone for domain %q: %w", domain, err) + return fmt.Errorf("webnamesru: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { - return fmt.Errorf("webnames: %w", err) + return fmt.Errorf("webnamesru: %w", err) } err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(authZone), subDomain, info.Value) if err != nil { - return fmt.Errorf("webnames: failed to create TXT records [domain: %s, sub domain: %s]: %w", + return fmt.Errorf("webnamesru: failed to create TXT records [domain: %s, sub domain: %s]: %w", dns01.UnFqdn(authZone), subDomain, err) } @@ -112,17 +120,17 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("webnames: could not find zone for domain %q: %w", domain, err) + return fmt.Errorf("webnamesru: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { - return fmt.Errorf("webnames: %w", err) + return fmt.Errorf("webnamesru: %w", err) } err = d.client.RemoveTXTRecord(context.Background(), dns01.UnFqdn(authZone), subDomain, info.Value) if err != nil { - return fmt.Errorf("webnames: failed to remove TXT records [domain: %s, sub domain: %s]: %w", + return fmt.Errorf("webnamesru: failed to remove TXT records [domain: %s, sub domain: %s]: %w", dns01.UnFqdn(authZone), subDomain, err) } @@ -134,3 +142,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } + +func altEnvName(v string) string { + return strings.ReplaceAll(v, envNamespace, altEnvNamespace) +} diff --git a/providers/dns/webnames/webnames.toml b/providers/dns/webnames/webnames.toml index 030d385c9..b038deaf5 100644 --- a/providers/dns/webnames/webnames.toml +++ b/providers/dns/webnames/webnames.toml @@ -1,12 +1,13 @@ -Name = "Webnames" +Name = "webnames.ru" Description = '''''' URL = "https://www.webnames.ru/" Code = "webnames" +Aliases = ["webnamesru"] Since = "v4.15.0" Example = ''' -WEBNAMES_API_KEY=xxxxxx \ -lego --email you@example.com --dns webnames -d '*.example.com' -d example.com run +WEBNAMESRU_API_KEY=xxxxxx \ +lego --dns webnamesru -d '*.example.com' -d example.com run ''' Additional = ''' @@ -19,12 +20,11 @@ The API key can be found: Personal account / My domains and services / Select th [Configuration] [Configuration.Credentials] - WEBNAMES_API_KEY = "Domain API key" + WEBNAMESRU_API_KEY = "Domain API key" [Configuration.Additional] - WEBNAMES_POLLING_INTERVAL = "Time between DNS propagation check" - WEBNAMES_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - WEBNAMES_TTL = "The TTL of the TXT record used for the DNS challenge" - WEBNAMES_HTTP_TIMEOUT = "API request timeout" + WEBNAMESRU_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + WEBNAMESRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + WEBNAMESRU_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://github.com/regtime-ltd/certbot-dns-webnames" diff --git a/providers/dns/webnames/webnames_test.go b/providers/dns/webnames/webnames_test.go index 3ec69501f..072591c68 100644 --- a/providers/dns/webnames/webnames_test.go +++ b/providers/dns/webnames/webnames_test.go @@ -29,13 +29,14 @@ func TestNewDNSProvider(t *testing.T) { envVars: map[string]string{ EnvAPIKey: "", }, - expected: "webnames: some credentials information are missing: WEBNAMES_API_KEY", + expected: "webnamesru: some credentials information are missing: WEBNAMESRU_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -65,7 +66,7 @@ func TestNewDNSProviderConfig(t *testing.T) { }, { desc: "missing credentials", - expected: "webnames: credentials missing", + expected: "webnamesru: credentials missing", }, } @@ -93,6 +94,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -106,6 +108,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/webnamesca/internal/client.go b/providers/dns/webnamesca/internal/client.go new file mode 100644 index 000000000..203ff9eac --- /dev/null +++ b/providers/dns/webnamesca/internal/client.go @@ -0,0 +1,162 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://www.webnames.ca/_/APICore" + +// Client the webnames.ca API client. +type Client struct { + user string + key string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(user, key string) (*Client, error) { + if user == "" || key == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + user: user, + key: key, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) AddTXTRecord(ctx context.Context, domainName, hostName, value string) ([]DNSRecordSet, error) { + endpoint := c.BaseURL.JoinPath("domains", domainName, "add-txt-record") + + query := endpoint.Query() + query.Set("hostName", hostName) + query.Set("txt", value) + + endpoint.RawQuery = query.Encode() + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, nil) + if err != nil { + return nil, err + } + + var result APIResponse[*DNSInfo] + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result.Result.DNSRecordSets, nil +} + +func (c *Client) DeleteTXTRecord(ctx context.Context, domainName, hostName, value string) ([]DNSRecordSet, error) { + endpoint := c.BaseURL.JoinPath("domains", domainName, "delete-txt-record") + + query := endpoint.Query() + query.Set("hostName", hostName) + query.Set("txt", value) + + endpoint.RawQuery = query.Encode() + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return nil, err + } + + var result APIResponse[*DNSInfo] + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result.Result.DNSRecordSets, nil +} + +func (c *Client) do(req *http.Request, result any) error { + useragent.SetHeader(req.Header) + + req.Header.Set("API-User", c.user) + req.Header.Set("API-Key", c.key) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return &errAPI +} diff --git a/providers/dns/webnamesca/internal/client_test.go b/providers/dns/webnamesca/internal/client_test.go new file mode 100644 index 000000000..ad8571ed0 --- /dev/null +++ b/providers/dns/webnamesca/internal/client_test.go @@ -0,0 +1,96 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("user", "secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + With("API-User", "user"). + With("API-Key", "secret"). + WithJSONHeaders(), + ) +} + +func TestClient_AddTXTRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/example.com/add-txt-record", + servermock.ResponseFromFixture("add_txt_record.json"), + servermock.CheckQueryParameter().Strict(). + With("hostName", "foo.example.com"). + With("txt", "value")). + Build(t) + + result, err := client.AddTXTRecord(t.Context(), "example.com", "foo.example.com", "value") + require.NoError(t, err) + + expected := []DNSRecordSet{{ + Hostname: "_acme-challenge.example.com", + Type: "TXT", + Records: []string{"value"}, + }} + + assert.Equal(t, expected, result) +} + +func TestClient_AddTXTRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/example.com/add-txt-record", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + _, err := client.AddTXTRecord(t.Context(), "example.com", "foo.example.com", "value") + require.EqualError(t, err, "message: User does not exist., details: string, logiD: 35579, result: {}") +} + +func TestClient_DeleteTXTRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domains/example.com/delete-txt-record", + servermock.ResponseFromFixture("delete_txt_record.json"), + servermock.CheckQueryParameter().Strict(). + With("hostName", "foo.example.com"). + With("txt", "value")). + Build(t) + + result, err := client.DeleteTXTRecord(t.Context(), "example.com", "foo.example.com", "value") + require.NoError(t, err) + + expected := []DNSRecordSet{{ + Hostname: "_acme-challenge.example.com", + Type: "TXT", + Records: []string{"value"}, + }} + + assert.Equal(t, expected, result) +} + +func TestClient_DeleteTXTRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domains/example.com/delete-txt-record", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + _, err := client.DeleteTXTRecord(t.Context(), "example.com", "foo.example.com", "value") + require.EqualError(t, err, "message: User does not exist., details: string, logiD: 35579, result: {}") +} diff --git a/providers/dns/webnamesca/internal/fixtures/add_txt_record.json b/providers/dns/webnamesca/internal/fixtures/add_txt_record.json new file mode 100644 index 000000000..9754689a7 --- /dev/null +++ b/providers/dns/webnamesca/internal/fixtures/add_txt_record.json @@ -0,0 +1,34 @@ +{ + "result": { + "domainAdvancedDNSConfigID": 3258480, + "domainID": 1333334, + "dtCreated": "2025-10-30T11:55:23.243", + "dtModified": "2025-10-30T11:55:23.177", + "timeToLive": 21600, + "soAorigin": "hosting.webnames.ca", + "soArefresh": 21600, + "soAretry": 180, + "soAexpire": 1209600, + "soAnegcache": 3600, + "forwardingURL": null, + "gripping": false, + "name": null, + "dtSubmitted": "2025-10-30T11:55:24.927", + "dtRequestedDNSChange": null, + "type": "REAL_DOMAIN", + "userManaged": false, + "effectiveMgmtOption": "AD", + "urlForwardRootOnly": false, + "enableDNSSEC": false, + "dnsRecordSets": [ + { + "hostname": "_acme-challenge.example.com", + "type": "TXT", + "records": [ + "value" + ] + } + ] + }, + "logID": 36014 +} diff --git a/providers/dns/webnamesca/internal/fixtures/delete_txt_record.json b/providers/dns/webnamesca/internal/fixtures/delete_txt_record.json new file mode 100644 index 000000000..be2279ef6 --- /dev/null +++ b/providers/dns/webnamesca/internal/fixtures/delete_txt_record.json @@ -0,0 +1,36 @@ +{ + "errorMessage": "string", + "errorDetails": "string", + "logID": 0, + "result": { + "domainAdvancedDNSConfigID": 0, + "domainID": 0, + "dtCreated": "2025-10-29T21:22:31.478", + "dtModified": "2025-10-29T21:22:31.478", + "timeToLive": 0, + "soAorigin": "string", + "soArefresh": 0, + "soAretry": 0, + "soAexpire": 0, + "soAnegcache": 0, + "forwardingURL": "string", + "gripping": true, + "name": "string", + "dtSubmitted": "2025-10-29T21:22:31.478", + "dtRequestedDNSChange": "2025-10-29T21:22:31.478", + "type": "string", + "userManaged": true, + "effectiveMgmtOption": "string", + "urlForwardRootOnly": true, + "enableDNSSEC": true, + "dnsRecordSets": [ + { + "hostname": "_acme-challenge.example.com", + "type": "TXT", + "records": [ + "value" + ] + } + ] + } +} diff --git a/providers/dns/webnamesca/internal/fixtures/error.json b/providers/dns/webnamesca/internal/fixtures/error.json new file mode 100644 index 000000000..3e7548abb --- /dev/null +++ b/providers/dns/webnamesca/internal/fixtures/error.json @@ -0,0 +1,6 @@ +{ + "errorMessage": "User does not exist.", + "errorDetails": "string", + "logID": 35579, + "result": {} +} diff --git a/providers/dns/webnamesca/internal/types.go b/providers/dns/webnamesca/internal/types.go new file mode 100644 index 000000000..8dc56c33a --- /dev/null +++ b/providers/dns/webnamesca/internal/types.go @@ -0,0 +1,33 @@ +package internal + +import ( + "encoding/json" + "fmt" +) + +type APIError struct { + ErrorMessage string `json:"errorMessage,omitempty"` + ErrorDetails string `json:"errorDetails,omitempty"` + LogID int `json:"logID,omitempty"` + Result json.RawMessage `json:"result,omitempty"` +} + +func (a *APIError) Error() string { + return fmt.Sprintf("message: %s, details: %s, logiD: %d, result: %s", a.ErrorMessage, a.ErrorDetails, a.LogID, a.Result) +} + +type APIResponse[T any] struct { + Result T `json:"result,omitempty"` + LogID int `json:"logID,omitempty"` +} + +type DNSInfo struct { + DomainID int `json:"domainID,omitempty"` + DNSRecordSets []DNSRecordSet `json:"dnsRecordSets,omitempty"` +} + +type DNSRecordSet struct { + Hostname string `json:"hostname"` + Type string `json:"type"` + Records []string `json:"records"` +} diff --git a/providers/dns/webnamesca/webnamesca.go b/providers/dns/webnamesca/webnamesca.go new file mode 100644 index 000000000..874c1c48e --- /dev/null +++ b/providers/dns/webnamesca/webnamesca.go @@ -0,0 +1,134 @@ +// Package webnamesca implements a DNS provider for solving the DNS-01 challenge using webnames.ca. +package webnamesca + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/webnamesca/internal" +) + +// Environment variables names. +const ( + envNamespace = "WEBNAMESCA_" + + EnvAPIUser = envNamespace + "API_USER" + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIUser string + APIKey string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for webnames.ca. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIUser, EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("webnamesca: %w", err) + } + + config := NewDefaultConfig() + config.APIUser = values[EnvAPIUser] + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for webnames.ca. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("webnamesca: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.APIUser, config.APIKey) + if err != nil { + return nil, fmt.Errorf("webnamesca: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("webnamesca: could not find zone for domain %q: %w", domain, err) + } + + _, err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(authZone), dns01.UnFqdn(info.EffectiveFQDN), info.Value) + if err != nil { + return fmt.Errorf("webnamesca: add TXT record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("webnamesca: could not find zone for domain %q: %w", domain, err) + } + + _, err = d.client.DeleteTXTRecord(context.Background(), dns01.UnFqdn(authZone), dns01.UnFqdn(info.EffectiveFQDN), info.Value) + if err != nil { + return fmt.Errorf("webnamesca: delete TXT record: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/webnamesca/webnamesca.toml b/providers/dns/webnamesca/webnamesca.toml new file mode 100644 index 000000000..ab68a04a0 --- /dev/null +++ b/providers/dns/webnamesca/webnamesca.toml @@ -0,0 +1,24 @@ +Name = "webnames.ca" +Description = '''''' +URL = "https://www.webnames.ca/" +Code = "webnamesca" +Since = "v4.28.0" + +Example = ''' +WEBNAMESCA_API_USER="xxx" \ +WEBNAMESCA_API_KEY="yyy" \ +lego --dns webnamesca -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + WEBNAMESCA_API_USER = "API username" + WEBNAMESCA_API_KEY = "API key" + [Configuration.Additional] + WEBNAMESCA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + WEBNAMESCA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + WEBNAMESCA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + WEBNAMESCA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://www.webnames.ca/_/swagger/index.html" diff --git a/providers/dns/webnamesca/webnamesca_test.go b/providers/dns/webnamesca/webnamesca_test.go new file mode 100644 index 000000000..0459ef44e --- /dev/null +++ b/providers/dns/webnamesca/webnamesca_test.go @@ -0,0 +1,199 @@ +package webnamesca + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIUser, EnvAPIKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIUser: "user", + EnvAPIKey: "secret", + }, + }, + { + desc: "missing EnvAPIUser", + envVars: map[string]string{ + EnvAPIUser: "", + EnvAPIKey: "secret", + }, + expected: "webnamesca: some credentials information are missing: WEBNAMESCA_API_USER", + }, + { + desc: "missing EnvAPIKey", + envVars: map[string]string{ + EnvAPIUser: "user", + EnvAPIKey: "", + }, + expected: "webnamesca: some credentials information are missing: WEBNAMESCA_API_KEY", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "webnamesca: some credentials information are missing: WEBNAMESCA_API_USER,WEBNAMESCA_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiUser string + apiKey string + expected string + }{ + { + desc: "success", + apiUser: "user", + apiKey: "secret", + }, + { + desc: "missing apiUser", + apiKey: "secret", + expected: "webnamesca: credentials missing", + }, + { + desc: "missing apiKey", + apiUser: "user", + expected: "webnamesca: credentials missing", + }, + { + desc: "missing credentials", + expected: "webnamesca: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIUser = test.apiUser + config.APIKey = test.apiKey + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIUser = "user" + config.APIKey = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With("API-User", "user"). + With("API-Key", "secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("POST /domains/example.com/add-txt-record", + servermock.ResponseFromInternal("add_txt_record.json"), + servermock.CheckQueryParameter().Strict(). + With("hostName", "_acme-challenge.example.com"). + With("txt", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY")). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("DELETE /domains/example.com/delete-txt-record", + servermock.ResponseFromInternal("delete_txt_record.json"), + servermock.CheckQueryParameter().Strict(). + With("hostName", "_acme-challenge.example.com"). + With("txt", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY")). + Build(t) + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/websupport/internal/client_test.go b/providers/dns/websupport/internal/client_test.go deleted file mode 100644 index 9612f6096..000000000 --- a/providers/dns/websupport/internal/client_test.go +++ /dev/null @@ -1,234 +0,0 @@ -package internal - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - open, err := os.Open(file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient("apiKey", "secretKey") - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client -} - -func TestClient_GetUser(t *testing.T) { - client := setupTest(t, http.MethodGet, "/v1/user/self", http.StatusOK, "./fixtures/get-user.json") - - user, err := client.GetUser(context.Background(), "self") - require.NoError(t, err) - - expected := &User{ - ID: 987654321, - Login: "lego@example.com", - Active: true, - CreateTime: 1675237889, - Group: "users", - Email: "lego@example.com", - Phone: "+123456789", - ContactPerson: "", - AwaitingTosConfirmation: "1", - UserLanguage: "sk-SK", - Credit: 0, - VerifyURL: "https://rest.websupport.sk/v1/user/verify/key/xxx", - Billing: []Billing{{ - ID: 1099970, - Profile: "default", - IsDefault: true, - Name: "asdsdfs", - City: "Žilina", - Street: "asddfsdfsdf", - Zip: "01234", - Country: "sk", - }}, - Market: Market{Name: "Slovakia", Identifier: "sk", Currency: "EUR"}, - } - - assert.Equal(t, expected, user) -} - -func TestClient_ListRecords(t *testing.T) { - client := setupTest(t, http.MethodGet, "/v1/user/self/zone/example.com/record", http.StatusOK, "./fixtures/list-records.json") - - resp, err := client.ListRecords(context.Background(), "example.com") - require.NoError(t, err) - - expected := &ListResponse{ - Items: []Record{ - { - ID: 1, - Type: "A", - Name: "@", - Content: "37.9.169.99", - TTL: 600, - }, { - ID: 2, - Type: "NS", - Name: "@", - Content: "ns1.scaledo.com", - TTL: 600, - }, - }, - Pager: Pager{Page: 1, PageSize: 0, Items: 2}, - } - - assert.Equal(t, expected, resp) -} - -func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, http.MethodPost, "/v1/user/self/zone/example.com/record", http.StatusCreated, "./fixtures/add-record.json") - - record := Record{ - Type: "TXT", - Name: "_acme-challenge", - Content: "txttxttxt", - TTL: 600, - } - - resp, err := client.AddRecord(context.Background(), "example.com", record) - require.NoError(t, err) - - expected := &Response{ - Status: "success", - Item: &Record{ - ID: 4, - Type: "A", - Name: "@", - Content: "1.2.3.4", - TTL: 600, - Zone: &Zone{ - ID: 1, - Name: "example.com", - UpdateTime: 1381169608, - }, - }, - Errors: json.RawMessage("[]"), - } - - assert.Equal(t, expected, resp) -} - -func TestClient_AddRecord_error_400(t *testing.T) { - client := setupTest(t, http.MethodPost, "/v1/user/self/zone/example.com/record", http.StatusBadRequest, "./fixtures/add-record-error-400.json") - - record := Record{ - Type: "TXT", - Name: "_acme-challenge", - Content: "txttxttxt", - TTL: 600, - } - - resp, err := client.AddRecord(context.Background(), "example.com", record) - require.NoError(t, err) - - assert.Equal(t, "error", resp.Status) - - expectedRecord := &Record{ - ID: 0, - Type: "A", - Name: "something bad !@#$%^&*(", - Content: "123.456.789.123", - TTL: 600, - Zone: &Zone{ - ID: 1, - Name: "scaledo.com", - UpdateTime: 1381169608, - }, - } - assert.Equal(t, expectedRecord, resp.Item) - - expected := &Errors{Name: []string{"Invalid input."}, Content: []string{"Wrong IP address format"}} - assert.Equal(t, expected, ParseError(resp)) -} - -func TestClient_AddRecord_error_404(t *testing.T) { - client := setupTest(t, http.MethodPost, "/v1/user/self/zone/example.com/record", http.StatusNotFound, "./fixtures/add-record-error-404.json") - - record := Record{ - Type: "TXT", - Name: "_acme-challenge", - Content: "txttxttxt", - TTL: 600, - } - - resp, err := client.AddRecord(context.Background(), "example.com", record) - require.Error(t, err) - - assert.Nil(t, resp) -} - -func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/v1/user/self/zone/example.com/record/123", http.StatusOK, "./fixtures/delete-record.json") - - resp, err := client.DeleteRecord(context.Background(), "example.com", 123) - require.NoError(t, err) - - expected := &Response{ - Status: "success", - Item: &Record{ - ID: 1, - Type: "A", - Name: "@", - Content: "1.2.3.4", - TTL: 600, - Zone: &Zone{ - ID: 1, - Name: "scaledo.com", - UpdateTime: 1381316081, - }, - }, - Errors: json.RawMessage("[]"), - } - - assert.Equal(t, expected, resp) -} - -func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/v1/user/self/zone/example.com/record/123", http.StatusNotFound, "./fixtures/delete-record-error-404.json") - - resp, err := client.DeleteRecord(context.Background(), "example.com", 123) - require.Error(t, err) - - assert.Nil(t, resp) -} diff --git a/providers/dns/websupport/internal/fixtures/add-record-error-400.json b/providers/dns/websupport/internal/fixtures/add-record-error-400.json deleted file mode 100644 index b60b7989a..000000000 --- a/providers/dns/websupport/internal/fixtures/add-record-error-400.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "status": "error", - "item": { - "id": null, - "type": "A", - "name": "something bad !@#$%^&*(", - "content": "123.456.789.123", - "ttl": 600, - "prio": null, - "weight": null, - "port": null, - "zone": { - "id": 1, - "name": "scaledo.com", - "updateTime": 1381169608 - } - }, - "errors": { - "content": [ - "Wrong IP address format" - ], - "name": [ - "Invalid input." - ] - } -} diff --git a/providers/dns/websupport/internal/fixtures/add-record-error-404.json b/providers/dns/websupport/internal/fixtures/add-record-error-404.json deleted file mode 100644 index 837b5392a..000000000 --- a/providers/dns/websupport/internal/fixtures/add-record-error-404.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "code": 404, - "message": "Zone not found" -} diff --git a/providers/dns/websupport/internal/fixtures/add-record.json b/providers/dns/websupport/internal/fixtures/add-record.json deleted file mode 100644 index 5990cf3d3..000000000 --- a/providers/dns/websupport/internal/fixtures/add-record.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "status": "success", - "item": { - "id": 4, - "type": "A", - "name": "@", - "content": "1.2.3.4", - "ttl": 600, - "prio": null, - "weight": null, - "port": null, - "zone": { - "id": 1, - "name": "example.com", - "updateTime": 1381169608 - } - }, - "errors": [] -} diff --git a/providers/dns/websupport/internal/fixtures/delete-record-error-404.json b/providers/dns/websupport/internal/fixtures/delete-record-error-404.json deleted file mode 100644 index e66fa5dc6..000000000 --- a/providers/dns/websupport/internal/fixtures/delete-record-error-404.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "code": 404, - "message": "Record not found" -} diff --git a/providers/dns/websupport/internal/fixtures/delete-record.json b/providers/dns/websupport/internal/fixtures/delete-record.json deleted file mode 100644 index 8fdff82cb..000000000 --- a/providers/dns/websupport/internal/fixtures/delete-record.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "status": "success", - "item": { - "id": 1, - "type": "A", - "name": "@", - "content": "1.2.3.4", - "ttl": 600, - "prio": null, - "weight": null, - "port": null, - "zone": { - "id": 1, - "name": "scaledo.com", - "updateTime": 1381316081 - } - }, - "errors": [] -} diff --git a/providers/dns/websupport/internal/fixtures/get-record.json b/providers/dns/websupport/internal/fixtures/get-record.json deleted file mode 100644 index d1bd2f137..000000000 --- a/providers/dns/websupport/internal/fixtures/get-record.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": 69966832, - "type": "TXT", - "name": "_acme-challenge", - "content": "txttxttxt", - "ttl": 600, - "zone": { - "id": 0, - "name": "example.com", - "updateTime": 1675240207 - } -} diff --git a/providers/dns/websupport/internal/fixtures/get-user.json b/providers/dns/websupport/internal/fixtures/get-user.json deleted file mode 100644 index ad4978755..000000000 --- a/providers/dns/websupport/internal/fixtures/get-user.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "id": 987654321, - "login": "lego@example.com", - "parentId": null, - "active": true, - "createTime": 1675237889, - "group": "users", - "email": "lego@example.com", - "phone": "+123456789", - "contactPerson": "", - "awaitingTosConfirmation": "1", - "userLanguage": "sk-SK", - "credit": 0, - "verifyUrl": "https:\/\/rest.websupport.sk\/v1\/user\/verify\/key\/xxx", - "billing": [ - { - "id": 1099970, - "profile": "default", - "isDefault": true, - "name": "asdsdfs", - "city": "\u017dilina", - "street": "asddfsdfsdf", - "companyRegId": null, - "taxId": null, - "vatId": null, - "zip": "01234", - "country": "sk", - "isic": "" - } - ], - "market": { - "name": "Slovakia", - "identifier": "sk", - "currency": "EUR" - } -} diff --git a/providers/dns/websupport/internal/fixtures/list-records.json b/providers/dns/websupport/internal/fixtures/list-records.json deleted file mode 100644 index d0ad57dc9..000000000 --- a/providers/dns/websupport/internal/fixtures/list-records.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "items": [ - { - "id": 1, - "type": "A", - "name": "@", - "content": "37.9.169.99", - "ttl": 600, - "prio": null, - "weight": null, - "port": null - }, - { - "id": 2, - "type": "NS", - "name": "@", - "content": "ns1.scaledo.com", - "ttl": 600, - "prio": null, - "weight": null, - "port": null - } - ], - "pager": { - "page": 1, - "pagesize": 0, - "items": 2 - } -} diff --git a/providers/dns/websupport/internal/types.go b/providers/dns/websupport/internal/types.go deleted file mode 100644 index 0923282aa..000000000 --- a/providers/dns/websupport/internal/types.go +++ /dev/null @@ -1,121 +0,0 @@ -package internal - -import ( - "encoding/json" - "fmt" -) - -type APIError struct { - Code int `json:"code"` - Message string `json:"message"` -} - -func (a *APIError) Error() string { - return fmt.Sprintf("%d: %s", a.Code, a.Message) -} - -type Record struct { - ID int `json:"id,omitempty"` - Type string `json:"type,omitempty"` - Name string `json:"name,omitempty"` // subdomain name or @ if you don't want subdomain - Content string `json:"content,omitempty"` - TTL int `json:"ttl,omitempty"` // default 600 - Zone *Zone `json:"zone"` -} - -type Zone struct { - ID int `json:"id"` - Name string `json:"name"` - UpdateTime int `json:"updateTime"` -} - -type Response struct { - Item *Record `json:"item"` - Status string `json:"status"` - Errors json.RawMessage `json:"errors"` -} - -type ListResponse struct { - Items []Record `json:"items"` - Pager Pager `json:"pager"` -} - -type Pager struct { - Page int `json:"page"` - PageSize int `json:"pagesize"` - Items int `json:"items"` -} - -type Errors struct { - Name []string `json:"name"` - Content []string `json:"content"` -} - -func (e *Errors) Error() string { - var msg string - for i, s := range e.Name { - msg += s - if i != len(e.Name)-1 { - msg += ": " - } - } - - for i, s := range e.Content { - msg += s - if i != len(e.Content)-1 { - msg += ": " - } - } - - return msg -} - -// ParseError extract error from Response. -func ParseError(resp *Response) error { - var errAPI Errors - err := json.Unmarshal(resp.Errors, &errAPI) - if err != nil { - return err - } - - return &errAPI -} - -type User struct { - ID int `json:"id"` - Login string `json:"login"` - ParentID int `json:"parentId"` - Active bool `json:"active"` - CreateTime int `json:"createTime"` - Group string `json:"group"` - Email string `json:"email"` - Phone string `json:"phone"` - ContactPerson string `json:"contactPerson"` - AwaitingTosConfirmation string `json:"awaitingTosConfirmation"` - UserLanguage string `json:"userLanguage"` - Credit int `json:"credit"` - VerifyURL string `json:"verifyUrl"` - Billing []Billing `json:"billing"` - Market Market `json:"market"` -} - -type Billing struct { - ID int `json:"id"` - Profile string `json:"profile"` - IsDefault bool `json:"isDefault"` - Name string `json:"name"` - City string `json:"city"` - Street string `json:"street"` - CompanyRegID int `json:"companyRegId"` - TaxID int `json:"taxId"` - VatID int `json:"vatId"` - Zip string `json:"zip"` - Country string `json:"country"` - ISIC string `json:"isic"` -} - -type Market struct { - Name string `json:"name"` - Identifier string `json:"identifier"` - Currency string `json:"currency"` -} diff --git a/providers/dns/websupport/websupport.go b/providers/dns/websupport/websupport.go index 3a257b425..4187ba32b 100644 --- a/providers/dns/websupport/websupport.go +++ b/providers/dns/websupport/websupport.go @@ -2,18 +2,19 @@ package websupport import ( - "context" "errors" "fmt" "net/http" - "sync" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" - "github.com/go-acme/lego/v4/providers/dns/websupport/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/active24" ) +const baseAPIDomain = "websupport.sk" + // Environment variables names. const ( envNamespace = "WEBSUPPORT_" @@ -25,28 +26,17 @@ const ( EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" - EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) // Config is used to configure the creation of the DNSProvider. -type Config struct { - APIKey string - Secret string - - PropagationTimeout time.Duration - PollingInterval time.Duration - SequenceInterval time.Duration - TTL int - HTTPClient *http.Client -} +type Config = active24.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - TTL: env.GetOrDefaultInt(EnvTTL, 600), + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), - SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, @@ -55,11 +45,7 @@ func NewDefaultConfig() *Config { // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { - config *Config - client *internal.Client - - recordIDs map[string]int - recordIDsMu sync.Mutex + prv challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for Websupport. @@ -83,101 +69,36 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("websupport: the configuration of the DNS provider is nil") } - client, err := internal.NewClient(config.APIKey, config.Secret) + provider, err := active24.NewDNSProviderConfig(config, baseAPIDomain) if err != nil { return nil, fmt.Errorf("websupport: %w", err) } - if config.HTTPClient != nil { - client.HTTPClient = config.HTTPClient - } - - return &DNSProvider{ - config: config, - client: client, - recordIDs: make(map[string]int), - }, nil + return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) - if err != nil { - return fmt.Errorf("websupport: could not find zone for domain %q: %w", domain, err) - } - - subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("websupport: %w", err) } - record := internal.Record{ - Type: "TXT", - Name: subDomain, - Content: info.Value, - TTL: d.config.TTL, - } - - resp, err := d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record) - if err != nil { - return fmt.Errorf("websupport: add record: %w", err) - } - - if resp.Status == internal.StatusSuccess { - d.recordIDsMu.Lock() - d.recordIDs[token] = resp.Item.ID - d.recordIDsMu.Unlock() - - return nil - } - - return fmt.Errorf("websupport: %w", internal.ParseError(resp)) + return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { - return fmt.Errorf("websupport: could not find zone for domain %q: %w", domain, err) + return fmt.Errorf("websupport: %w", err) } - // gets the record's unique ID - d.recordIDsMu.Lock() - recordID, ok := d.recordIDs[token] - d.recordIDsMu.Unlock() - if !ok { - return fmt.Errorf("websupport: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) - } - - resp, err := d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID) - if err != nil { - return fmt.Errorf("websupport: delete record: %w", err) - } - - // deletes record ID from map - d.recordIDsMu.Lock() - delete(d.recordIDs, token) - d.recordIDsMu.Unlock() - - if resp.Status == internal.StatusSuccess { - return nil - } - - return fmt.Errorf("websupport: %w", internal.ParseError(resp)) + return nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.config.PropagationTimeout, d.config.PollingInterval -} - -// Sequential All DNS challenges for this provider will be resolved sequentially. -// Returns the interval between each iteration. -func (d *DNSProvider) Sequential() time.Duration { - return d.config.SequenceInterval + return d.prv.Timeout() } diff --git a/providers/dns/websupport/websupport.toml b/providers/dns/websupport/websupport.toml index d1a0af7dc..4908f0235 100644 --- a/providers/dns/websupport/websupport.toml +++ b/providers/dns/websupport/websupport.toml @@ -7,7 +7,7 @@ Since = "v4.10.0" Example = ''' WEBSUPPORT_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ WEBSUPPORT_SECRET="yyyyyyyyyyyyyyyyyyyyy" \ -lego --email you@example.com --dns websupport -d '*.example.com' -d example.com run +lego --dns websupport -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,11 +15,12 @@ lego --email you@example.com --dns websupport -d '*.example.com' -d example.com WEBSUPPORT_API_KEY = "API key" WEBSUPPORT_SECRET = "API secret" [Configuration.Additional] - WEBSUPPORT_POLLING_INTERVAL = "Time between DNS propagation check" - WEBSUPPORT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - WEBSUPPORT_SEQUENCE_INTERVAL = "Time between sequential requests" - WEBSUPPORT_TTL = "The TTL of the TXT record used for the DNS challenge" - WEBSUPPORT_HTTP_TIMEOUT = "API request timeout" + WEBSUPPORT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + WEBSUPPORT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + WEBSUPPORT_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" + WEBSUPPORT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" + WEBSUPPORT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] - API = "https://rest.websupport.sk/docs/v1.zone" + API = "https://rest.websupport.sk/v2/docs" + APIv1 = "https://rest.websupport.sk/docs/v1.service#services" diff --git a/providers/dns/websupport/websupport_test.go b/providers/dns/websupport/websupport_test.go index e79dd7130..196c9bab8 100644 --- a/providers/dns/websupport/websupport_test.go +++ b/providers/dns/websupport/websupport_test.go @@ -20,13 +20,14 @@ func TestNewDNSProvider(t *testing.T) { { desc: "success", envVars: map[string]string{ - EnvAPIKey: "key", + EnvAPIKey: "user", EnvSecret: "secret", }, }, { desc: "missing API key", envVars: map[string]string{ + EnvAPIKey: "", EnvSecret: "secret", }, expected: "websupport: some credentials information are missing: WEBSUPPORT_API_KEY", @@ -34,7 +35,8 @@ func TestNewDNSProvider(t *testing.T) { { desc: "missing secret", envVars: map[string]string{ - EnvAPIKey: "key", + EnvAPIKey: "user", + EnvSecret: "", }, expected: "websupport: some credentials information are missing: WEBSUPPORT_SECRET", }, @@ -48,6 +50,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -57,8 +60,7 @@ func TestNewDNSProvider(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - require.NotNil(t, p.config) - require.NotNil(t, p.client) + require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -75,17 +77,19 @@ func TestNewDNSProviderConfig(t *testing.T) { }{ { desc: "success", - apiKey: "key", + apiKey: "user", secret: "secret", }, { desc: "missing API key", + apiKey: "", secret: "secret", expected: "websupport: credentials missing", }, { desc: "missing secret", - apiKey: "key", + apiKey: "user", + secret: "", expected: "websupport: credentials missing", }, { @@ -105,8 +109,7 @@ func TestNewDNSProviderConfig(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - require.NotNil(t, p.config) - require.NotNil(t, p.client) + require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -120,6 +123,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -133,6 +137,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/wedos/internal/client.go b/providers/dns/wedos/internal/client.go index defcabf6c..48c89d189 100644 --- a/providers/dns/wedos/internal/client.go +++ b/providers/dns/wedos/internal/client.go @@ -26,7 +26,7 @@ type Client struct { } // NewClient creates a new Client. -func NewClient(username string, password string) *Client { +func NewClient(username, password string) *Client { return &Client{ username: username, password: password, @@ -69,6 +69,7 @@ func (c *Client) AddRecord(ctx context.Context, zone string, record DNSRow) erro } cmd := commandDNSRowAdd + if record.ID == "" { payload.Name = record.Name } else { @@ -87,7 +88,7 @@ func (c *Client) AddRecord(ctx context.Context, zone string, record DNSRow) erro // DeleteRecord deletes a record from the zone. // If a record does not have an ID, it will be looked up. // https://kb.wedos.com/en/wapi-api-interface/wapi-command-dns-row-delete/ -func (c *Client) DeleteRecord(ctx context.Context, zone string, recordID string) error { +func (c *Client) DeleteRecord(ctx context.Context, zone, recordID string) error { payload := DNSRowRequest{ Domain: dns01.UnFqdn(zone), ID: recordID, diff --git a/providers/dns/wedos/internal/client_test.go b/providers/dns/wedos/internal/client_test.go index 30c7d4863..f2515618a 100644 --- a/providers/dns/wedos/internal/client_test.go +++ b/providers/dns/wedos/internal/client_test.go @@ -1,64 +1,38 @@ package internal import ( - "context" "fmt" "net/http" "net/http/httptest" - "os" "regexp" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupNew(t *testing.T, expectedForm string, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.baseURL = server.URL + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - err := req.ParseForm() - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - exp := regexp.MustCompile(`"auth":"\w+",`) - - form := req.PostForm.Get("request") - form = exp.ReplaceAllString(form, `"auth":"xxx",`) - - if form != expectedForm { - t.Logf("invalid form data: %s", req.PostForm.Get("request")) - http.Error(rw, fmt.Sprintf("invalid form data: %s", req.PostForm.Get("request")), http.StatusBadRequest) - return - } - - data, err := os.ReadFile(fmt.Sprintf("./fixtures/%s.json", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - rw.Header().Set("Content-Type", "application/json") - _, _ = rw.Write(data) - }) - - client := NewClient("user", "secret") - client.baseURL = server.URL - - return client + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()) } func TestClient_GetRecords(t *testing.T) { - expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-rows-list","data":{"domain":"example.com"}}}` - client := setupNew(t, expectedForm, commandDNSRowsList) + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture(commandDNSRowsList+".json"), + checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-rows-list","data":{"domain":"example.com"}}}`)). + Build(t) - records, err := client.GetRecords(context.Background(), "example.com.") + records, err := client.GetRecords(t.Context(), "example.com.") require.NoError(t, err) assert.Len(t, records, 4) @@ -95,9 +69,11 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_AddRecord(t *testing.T) { - expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-row-add","data":{"domain":"example.com","name":"foo","ttl":1800,"type":"TXT","rdata":"foobar"}}}` - - client := setupNew(t, expectedForm, commandDNSRowAdd) + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture(commandDNSRowAdd+".json"), + checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-row-add","data":{"domain":"example.com","name":"foo","ttl":1800,"type":"TXT","rdata":"foobar"}}}`)). + Build(t) record := DNSRow{ ID: "", @@ -107,14 +83,16 @@ func TestClient_AddRecord(t *testing.T) { Data: "foobar", } - err := client.AddRecord(context.Background(), "example.com.", record) + err := client.AddRecord(t.Context(), "example.com.", record) require.NoError(t, err) } func TestClient_AddRecord_update(t *testing.T) { - expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-row-update","data":{"row_id":"1","domain":"example.com","ttl":1800,"type":"TXT","rdata":"foobar"}}}` - - client := setupNew(t, expectedForm, commandDNSRowUpdate) + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture(commandDNSRowUpdate+".json"), + checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-row-update","data":{"row_id":"1","domain":"example.com","ttl":1800,"type":"TXT","rdata":"foobar"}}}`)). + Build(t) record := DNSRow{ ID: "1", @@ -124,24 +102,50 @@ func TestClient_AddRecord_update(t *testing.T) { Data: "foobar", } - err := client.AddRecord(context.Background(), "example.com.", record) + err := client.AddRecord(t.Context(), "example.com.", record) require.NoError(t, err) } func TestClient_DeleteRecord(t *testing.T) { - expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-row-delete","data":{"row_id":"1","domain":"example.com","rdata":""}}}` + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture(commandDNSRowDelete+".json"), + checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-row-delete","data":{"row_id":"1","domain":"example.com","rdata":""}}}`)). + Build(t) - client := setupNew(t, expectedForm, commandDNSRowDelete) - - err := client.DeleteRecord(context.Background(), "example.com.", "1") + err := client.DeleteRecord(t.Context(), "example.com.", "1") require.NoError(t, err) } func TestClient_Commit(t *testing.T) { - expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-domain-commit","data":{"name":"example.com"}}}` + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture(commandDNSDomainCommit+".json"), + checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-domain-commit","data":{"name":"example.com"}}}`)). + Build(t) - client := setupNew(t, expectedForm, commandDNSDomainCommit) - - err := client.Commit(context.Background(), "example.com.") + err := client.Commit(t.Context(), "example.com.") require.NoError(t, err) } + +func checkFormRequest(data string) servermock.LinkFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + err := req.ParseForm() + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + form := regexp.MustCompile(`"auth":"\w+",`). + ReplaceAllString(req.PostForm.Get("request"), `"auth":"xxx",`) + + if form != data { + http.Error(rw, fmt.Sprintf("invalid form data: %s", req.PostForm.Get("request")), http.StatusBadRequest) + return + } + + next.ServeHTTP(rw, req) + }) + } +} diff --git a/providers/dns/wedos/internal/token.go b/providers/dns/wedos/internal/token.go index b83b107c1..11e680cb8 100644 --- a/providers/dns/wedos/internal/token.go +++ b/providers/dns/wedos/internal/token.go @@ -8,13 +8,14 @@ import ( "time" ) -func authToken(userName string, wapiPass string) string { +func authToken(userName, wapiPass string) string { return sha1string(userName + sha1string(wapiPass) + czechHourString()) } func sha1string(txt string) string { h := sha1.New() _, _ = io.WriteString(h, txt) + return hex.EncodeToString(h.Sum(nil)) } @@ -46,18 +47,19 @@ func utcToCet(utc time.Time) time.Time { if utcMonth < time.March || utcMonth > time.October { return utc.Add(time.Hour) } + if utcMonth > time.March && utcMonth < time.October { return utc.Add(time.Hour * 2) } dayOff := 0 + breaking := time.Date(utc.Year(), utcMonth+1, dayOff, 1, 0, 0, 0, time.UTC) - for { - if breaking.Weekday() == time.Sunday { - break - } + for breaking.Weekday() != time.Sunday { dayOff-- + breaking = time.Date(utc.Year(), utcMonth+1, dayOff, 1, 0, 0, 0, time.UTC) + if dayOff < -7 { panic("safety exit to avoid infinite loop") } @@ -66,6 +68,7 @@ func utcToCet(utc time.Time) time.Time { if (utcMonth == time.March && utc.Before(breaking)) || (utcMonth == time.October && utc.After(breaking)) { return utc.Add(time.Hour) } + return utc.Add(time.Hour * 2) } diff --git a/providers/dns/wedos/wedos.go b/providers/dns/wedos/wedos.go index 0004c49f8..164fb5f10 100644 --- a/providers/dns/wedos/wedos.go +++ b/providers/dns/wedos/wedos.go @@ -9,8 +9,10 @@ import ( "strconv" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/wedos/internal" ) @@ -29,6 +31,8 @@ const ( const minTTL = 5 * 60 // 5 minutes +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Username string @@ -91,6 +95,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{config: config, client: client}, nil } diff --git a/providers/dns/wedos/wedos.toml b/providers/dns/wedos/wedos.toml index 64845536e..89abfc16c 100644 --- a/providers/dns/wedos/wedos.toml +++ b/providers/dns/wedos/wedos.toml @@ -7,7 +7,7 @@ Since = "v4.4.0" Example = ''' WEDOS_USERNAME=xxxxxxxx \ WEDOS_WAPI_PASSWORD=xxxxxxxx \ -lego --email you@example.com --dns wedos -d '*.example.com' -d example.com run +lego --dns wedos -d '*.example.com' -d example.com run ''' [Configuration] @@ -15,10 +15,10 @@ lego --email you@example.com --dns wedos -d '*.example.com' -d example.com run WEDOS_USERNAME = "Username is the same as for the admin account" WEDOS_WAPI_PASSWORD = "Password needs to be generated and IP allowed in the admin interface" [Configuration.Additional] - WEDOS_POLLING_INTERVAL = "Time between DNS propagation check" - WEDOS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - WEDOS_HTTP_TIMEOUT = "API request timeout" - WEDOS_TTL = "The TTL of the TXT record used for the DNS challenge" + WEDOS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + WEDOS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)" + WEDOS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + WEDOS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://kb.wedos.com/en/kategorie/wapi-api-interface/wdns-en/" diff --git a/providers/dns/wedos/wedos_test.go b/providers/dns/wedos/wedos_test.go index 9363002b5..25f70d0fc 100644 --- a/providers/dns/wedos/wedos_test.go +++ b/providers/dns/wedos/wedos_test.go @@ -54,6 +54,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -120,6 +121,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -133,6 +135,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/westcn/westcn.go b/providers/dns/westcn/westcn.go new file mode 100644 index 000000000..1906f9737 --- /dev/null +++ b/providers/dns/westcn/westcn.go @@ -0,0 +1,104 @@ +// Package westcn implements a DNS provider for solving the DNS-01 challenge using West.cn/西部数码. +package westcn + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/westcn" +) + +// Environment variables names. +const ( + envNamespace = "WESTCN_" + + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "PASSWORD" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +const defaultBaseURL = "https://api.west.cn/api/v2" + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +type Config = westcn.Config + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, 60), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + prv challenge.ProviderTimeout +} + +// NewDNSProvider returns a DNSProvider instance configured for West.cn/西部数码. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvPassword) + if err != nil { + return nil, fmt.Errorf("westcn: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for West.cn/西部数码. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("westcn: the configuration of the DNS provider is nil") + } + + provider, err := westcn.NewDNSProviderConfig(config, defaultBaseURL) + if err != nil { + return nil, fmt.Errorf("westcn: %w", err) + } + + return &DNSProvider{prv: provider}, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + err := d.prv.Present(domain, token, keyAuth) + if err != nil { + return fmt.Errorf("westcn: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + err := d.prv.CleanUp(domain, token, keyAuth) + if err != nil { + return fmt.Errorf("westcn: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.prv.Timeout() +} diff --git a/providers/dns/westcn/westcn.toml b/providers/dns/westcn/westcn.toml new file mode 100644 index 000000000..1b0cb0a7a --- /dev/null +++ b/providers/dns/westcn/westcn.toml @@ -0,0 +1,24 @@ +Name = "West.cn/西部数码" +Description = '''''' +URL = "https://www.west.cn" +Code = "westcn" +Since = "v4.21.0" + +Example = ''' +WESTCN_USERNAME="xxx" \ +WESTCN_PASSWORD="yyy" \ +lego --dns westcn -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + WESTCN_USERNAME = "Username" + WESTCN_PASSWORD = "API password" + [Configuration.Additional] + WESTCN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" + WESTCN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + WESTCN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" + WESTCN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://www.west.cn/CustomerCenter/doc/domain_v2.html" diff --git a/providers/dns/westcn/westcn_test.go b/providers/dns/westcn/westcn_test.go new file mode 100644 index 000000000..a546d518e --- /dev/null +++ b/providers/dns/westcn/westcn_test.go @@ -0,0 +1,144 @@ +package westcn + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "secret", + }, + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvUsername: "", + EnvPassword: "secret", + }, + expected: "westcn: some credentials information are missing: WESTCN_USERNAME", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "", + }, + expected: "westcn: some credentials information are missing: WESTCN_PASSWORD", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "westcn: some credentials information are missing: WESTCN_USERNAME,WESTCN_PASSWORD", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.prv) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + username string + password string + expected string + }{ + { + desc: "success", + username: "user", + password: "secret", + }, + { + desc: "missing username", + password: "secret", + expected: "westcn: credentials missing", + }, + { + desc: "missing password", + username: "user", + expected: "westcn: credentials missing", + }, + { + desc: "missing credentials", + expected: "westcn: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Username = test.username + config.Password = test.password + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.prv) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/yandex/internal/client.go b/providers/dns/yandex/internal/client.go index 5d7e6bff3..4b0421f49 100644 --- a/providers/dns/yandex/internal/client.go +++ b/providers/dns/yandex/internal/client.go @@ -12,7 +12,7 @@ import ( "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" - "github.com/google/go-querystring/query" + querystring "github.com/google/go-querystring/query" ) const defaultBaseURL = "https://pddimp.yandex.ru/api2/admin/dns" @@ -51,6 +51,7 @@ func (c *Client) AddRecord(ctx context.Context, payload Record) (*Record, error) } r := AddResponse{} + err = c.do(req, &r) if err != nil { return nil, err @@ -68,6 +69,7 @@ func (c *Client) RemoveRecord(ctx context.Context, payload Record) (int, error) } r := RemoveResponse{} + err = c.do(req, &r) if err != nil { return 0, err @@ -89,6 +91,7 @@ func (c *Client) GetRecords(ctx context.Context, domain string) ([]Record, error } r := ListResponse{} + err = c.do(req, &r) if err != nil { return nil, err @@ -130,7 +133,7 @@ func newRequest(ctx context.Context, method string, endpoint *url.URL, payload a if payload != nil { switch method { case http.MethodPost: - values, err := query.Values(payload) + values, err := querystring.Values(payload) if err != nil { return nil, err } @@ -138,7 +141,7 @@ func newRequest(ctx context.Context, method string, endpoint *url.URL, payload a buf.WriteString(values.Encode()) case http.MethodGet: - values, err := query.Values(payload) + values, err := querystring.Values(payload) if err != nil { return nil, err } diff --git a/providers/dns/yandex/internal/client_test.go b/providers/dns/yandex/internal/client_test.go index 67166ee85..4bb3357a6 100644 --- a/providers/dns/yandex/internal/client_test.go +++ b/providers/dns/yandex/internal/client_test.go @@ -1,328 +1,133 @@ package internal import ( - "context" - "encoding/json" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client, err := NewClient("lego") - require.NoError(t, err) + if err != nil { + return nil, err + } client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) - return client, mux + return client, nil } func TestAddRecord(t *testing.T) { - testCases := []struct { - desc string - handler http.HandlerFunc - data Record - expectError bool - }{ - { - desc: "success", - handler: func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "lego", r.Header.Get(pddTokenHeader)) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /add", + servermock.ResponseFromFixture("add_record.json"), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + servermock.CheckForm().Strict(). + With("domain", "example.com"). + With("subdomain", "foo"). + With("ttl", "300"). + With("content", "txtTXTtxtTXTtxtTXT"). + With("type", "TXT")). + Build(t) - err := r.ParseForm() - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - assert.Equal(t, `content=txtTXTtxtTXTtxtTXT&domain=example.com&subdomain=foo&ttl=300&type=TXT`, r.PostForm.Encode()) - - response := AddResponse{ - Domain: "example.com", - Record: &Record{ - ID: 1, - Type: "TXT", - Domain: "example.com", - SubDomain: "foo", - FQDN: "foo.example.com.", - Content: "txtTXTtxtTXTtxtTXT", - TTL: 300, - }, - BaseResponse: BaseResponse{ - Success: "ok", - }, - } - - err = json.NewEncoder(w).Encode(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }, - data: Record{ - Domain: "example.com", - Type: "TXT", - Content: "txtTXTtxtTXTtxtTXT", - SubDomain: "foo", - TTL: 300, - }, - }, - { - desc: "error", - handler: func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "lego", r.Header.Get(pddTokenHeader)) - - err := r.ParseForm() - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - assert.Equal(t, `content=txtTXTtxtTXTtxtTXT&domain=example.com&subdomain=foo&ttl=300&type=TXT`, r.PostForm.Encode()) - - response := AddResponse{ - Domain: "example.com", - BaseResponse: BaseResponse{ - Success: "error", - Error: "bad things", - }, - } - - err = json.NewEncoder(w).Encode(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }, - data: Record{ - Domain: "example.com", - Type: "TXT", - Content: "txtTXTtxtTXTtxtTXT", - SubDomain: "foo", - TTL: 300, - }, - expectError: true, - }, + data := Record{ + Domain: "example.com", + Type: "TXT", + Content: "txtTXTtxtTXTtxtTXT", + SubDomain: "foo", + TTL: 300, } - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - client, mux := setupTest(t) + record, err := client.AddRecord(t.Context(), data) + require.NoError(t, err) + require.NotNil(t, record) +} - mux.HandleFunc("/add", test.handler) +func TestAddRecord_error(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /add", + servermock.ResponseFromFixture("add_record_error.json"), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()). + Build(t) - record, err := client.AddRecord(context.Background(), test.data) - if test.expectError { - require.Error(t, err) - require.Nil(t, record) - } else { - require.NoError(t, err) - require.NotNil(t, record) - } - }) + data := Record{ + Domain: "example.com", + Type: "TXT", + Content: "txtTXTtxtTXTtxtTXT", + SubDomain: "foo", + TTL: 300, } + + _, err := client.AddRecord(t.Context(), data) + require.EqualError(t, err, "error during operation: error bad things") } func TestRemoveRecord(t *testing.T) { - testCases := []struct { - desc string - handler http.HandlerFunc - data Record - expectError bool - }{ - { - desc: "success", - handler: func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "lego", r.Header.Get(pddTokenHeader)) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /del", + servermock.ResponseFromFixture("remove_record.json"), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + servermock.CheckForm().Strict(). + With("domain", "example.com"). + With("record_id", "6")). + Build(t) - err := r.ParseForm() - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - assert.Equal(t, `domain=example.com&record_id=6`, r.PostForm.Encode()) - - response := RemoveResponse{ - Domain: "example.com", - RecordID: 6, - BaseResponse: BaseResponse{ - Success: "ok", - }, - } - - err = json.NewEncoder(w).Encode(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }, - data: Record{ - ID: 6, - Domain: "example.com", - }, - }, - { - desc: "error", - handler: func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "lego", r.Header.Get(pddTokenHeader)) - - err := r.ParseForm() - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - assert.Equal(t, `domain=example.com&record_id=6`, r.PostForm.Encode()) - - response := RemoveResponse{ - Domain: "example.com", - RecordID: 6, - BaseResponse: BaseResponse{ - Success: "error", - Error: "bad things", - }, - } - - err = json.NewEncoder(w).Encode(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }, - data: Record{ - ID: 6, - Domain: "example.com", - }, - expectError: true, - }, + data := Record{ + ID: 6, + Domain: "example.com", } - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - client, mux := setupTest(t) + id, err := client.RemoveRecord(t.Context(), data) + require.NoError(t, err) - mux.HandleFunc("/del", test.handler) + assert.Equal(t, 6, id) +} - id, err := client.RemoveRecord(context.Background(), test.data) - if test.expectError { - require.Error(t, err) - require.Equal(t, 0, id) - } else { - require.NoError(t, err) - require.Equal(t, 6, id) - } - }) +func TestRemoveRecord_error(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /del", + servermock.ResponseFromFixture("remove_record_error.json"), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()). + Build(t) + + data := Record{ + ID: 6, + Domain: "example.com", } + + _, err := client.RemoveRecord(t.Context(), data) + require.EqualError(t, err, "error during operation: error bad things") } func TestGetRecords(t *testing.T) { - testCases := []struct { - desc string - handler http.HandlerFunc - domain string - expectError bool - }{ - { - desc: "success", - handler: func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "lego", r.Header.Get(pddTokenHeader)) + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /list", + servermock.ResponseFromFixture("get_records.json"), + servermock.CheckForm().Strict(). + With("domain", "example.com")). + Build(t) - assert.Equal(t, "domain=example.com", r.URL.RawQuery) + records, err := client.GetRecords(t.Context(), "example.com") + require.NoError(t, err) - response := ListResponse{ - Domain: "example.com", - Records: []Record{ - { - ID: 1, - Type: "TXT", - Domain: "example.com", - SubDomain: "foo", - FQDN: "foo.example.com.", - Content: "txtTXTtxtTXTtxtTXT", - TTL: 300, - }, - { - ID: 2, - Type: "NS", - Domain: "example.com", - SubDomain: "foo", - FQDN: "foo.example.com.", - Content: "bar", - TTL: 300, - }, - }, - BaseResponse: BaseResponse{ - Success: "ok", - }, - } - - err := json.NewEncoder(w).Encode(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }, - domain: "example.com", - }, - { - desc: "error", - handler: func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "lego", r.Header.Get(pddTokenHeader)) - - assert.Equal(t, "domain=example.com", r.URL.RawQuery) - - response := ListResponse{ - Domain: "example.com", - BaseResponse: BaseResponse{ - Success: "error", - Error: "bad things", - }, - } - - err := json.NewEncoder(w).Encode(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }, - domain: "example.com", - expectError: true, - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - client, mux := setupTest(t) - - mux.HandleFunc("/list", test.handler) - - records, err := client.GetRecords(context.Background(), test.domain) - if test.expectError { - require.Error(t, err) - require.Empty(t, records) - } else { - require.NoError(t, err) - require.Len(t, records, 2) - } - }) - } + require.Len(t, records, 2) +} + +func TestGetRecords_error(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /list", + servermock.ResponseFromFixture("get_records_error.json")). + Build(t) + + _, err := client.GetRecords(t.Context(), "example.com") + require.EqualError(t, err, "error during operation: error bad things") } diff --git a/providers/dns/yandex/internal/fixtures/add_record.json b/providers/dns/yandex/internal/fixtures/add_record.json new file mode 100644 index 000000000..1e4452d1d --- /dev/null +++ b/providers/dns/yandex/internal/fixtures/add_record.json @@ -0,0 +1,13 @@ +{ + "success": "ok", + "domain": "example.com", + "record": { + "record_id": 1, + "domain": "example.com", + "subdomain": "foo", + "fqdn": "foo.example.com.", + "ttl": 300, + "type": "TXT", + "content": "txtTXTtxtTXTtxtTXT" + } +} diff --git a/providers/dns/yandex/internal/fixtures/add_record_error.json b/providers/dns/yandex/internal/fixtures/add_record_error.json new file mode 100644 index 000000000..932ccd674 --- /dev/null +++ b/providers/dns/yandex/internal/fixtures/add_record_error.json @@ -0,0 +1,5 @@ +{ + "success": "error", + "error": "bad things", + "domain": "example.com" +} diff --git a/providers/dns/yandex/internal/fixtures/get_records.json b/providers/dns/yandex/internal/fixtures/get_records.json new file mode 100644 index 000000000..e538834b4 --- /dev/null +++ b/providers/dns/yandex/internal/fixtures/get_records.json @@ -0,0 +1,24 @@ +{ + "success": "ok", + "domain": "example.com", + "records": [ + { + "record_id": 1, + "domain": "example.com", + "subdomain": "foo", + "fqdn": "foo.example.com.", + "ttl": 300, + "type": "TXT", + "content": "txtTXTtxtTXTtxtTXT" + }, + { + "record_id": 2, + "domain": "example.com", + "subdomain": "foo", + "fqdn": "foo.example.com.", + "ttl": 300, + "type": "NS", + "content": "bar" + } + ] +} diff --git a/providers/dns/yandex/internal/fixtures/get_records_error.json b/providers/dns/yandex/internal/fixtures/get_records_error.json new file mode 100644 index 000000000..932ccd674 --- /dev/null +++ b/providers/dns/yandex/internal/fixtures/get_records_error.json @@ -0,0 +1,5 @@ +{ + "success": "error", + "error": "bad things", + "domain": "example.com" +} diff --git a/providers/dns/yandex/internal/fixtures/remove_record.json b/providers/dns/yandex/internal/fixtures/remove_record.json new file mode 100644 index 000000000..3241ba9dc --- /dev/null +++ b/providers/dns/yandex/internal/fixtures/remove_record.json @@ -0,0 +1,5 @@ +{ + "success": "ok", + "domain": "example.com", + "record_id": 6 +} diff --git a/providers/dns/yandex/internal/fixtures/remove_record_error.json b/providers/dns/yandex/internal/fixtures/remove_record_error.json new file mode 100644 index 000000000..cd1471c9d --- /dev/null +++ b/providers/dns/yandex/internal/fixtures/remove_record_error.json @@ -0,0 +1,6 @@ +{ + "success": "error", + "error": "bad things", + "domain": "example.com", + "record_id": 6 +} diff --git a/providers/dns/yandex/internal/types.go b/providers/dns/yandex/internal/types.go index ed1873cef..48a85042c 100644 --- a/providers/dns/yandex/internal/types.go +++ b/providers/dns/yandex/internal/types.go @@ -30,18 +30,21 @@ func (r BaseResponse) GetError() string { type AddResponse struct { BaseResponse + Domain string `json:"domain,omitempty"` Record *Record `json:"record,omitempty"` } type RemoveResponse struct { BaseResponse + Domain string `json:"domain,omitempty"` RecordID int `json:"record_id,omitempty"` } type ListResponse struct { BaseResponse + Domain string `json:"domain,omitempty"` Records []Record `json:"records,omitempty"` } diff --git a/providers/dns/yandex/yandex.go b/providers/dns/yandex/yandex.go index 2886a0333..7ae505ec0 100644 --- a/providers/dns/yandex/yandex.go +++ b/providers/dns/yandex/yandex.go @@ -8,8 +8,10 @@ import ( "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/yandex/internal" "github.com/miekg/dns" ) @@ -26,6 +28,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { PddToken string @@ -85,6 +89,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{client: client, config: config}, nil } @@ -130,6 +136,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } var record *internal.Record + for _, rcd := range records { if rcd.Type == "TXT" && rcd.SubDomain == subDomain && rcd.Content == info.Value { record = &rcd @@ -150,6 +157,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("yandex: %w", err) } + return nil } diff --git a/providers/dns/yandex/yandex.toml b/providers/dns/yandex/yandex.toml index 91adf4658..a36df069e 100644 --- a/providers/dns/yandex/yandex.toml +++ b/providers/dns/yandex/yandex.toml @@ -7,17 +7,17 @@ Since = "v3.7.0" Example = ''' YANDEX_PDD_TOKEN= \ -lego --email you@example.com --dns yandex -d '*.example.com' -d example.com run +lego --dns yandex -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] YANDEX_PDD_TOKEN = "Basic authentication username" [Configuration.Additional] - YANDEX_POLLING_INTERVAL = "Time between DNS propagation check" - YANDEX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - YANDEX_HTTP_TIMEOUT = "API request timeout" - YANDEX_TTL = "The TTL of the TXT record used for the DNS challenge" + YANDEX_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + YANDEX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + YANDEX_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600)" + YANDEX_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://yandex.com/dev/domain/doc/concepts/api-dns.html" diff --git a/providers/dns/yandex/yandex_test.go b/providers/dns/yandex/yandex_test.go index 144a24126..8a0a7534a 100644 --- a/providers/dns/yandex/yandex_test.go +++ b/providers/dns/yandex/yandex_test.go @@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -95,6 +96,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -108,6 +110,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/yandex360/internal/client.go b/providers/dns/yandex360/internal/client.go index 2bebc6c20..33aeb0daa 100644 --- a/providers/dns/yandex360/internal/client.go +++ b/providers/dns/yandex360/internal/client.go @@ -47,7 +47,7 @@ func NewClient(oauthToken string, orgID int64) (*Client, error) { // AddRecord Adds a DNS record. // POST https://api30.yandex.net/directory/v1/org/{orgId}/domains/{domain}/dns // https://yandex.ru/dev/api360/doc/ref/DomainDNSService/DomainDNSService_Create.html -func (c Client) AddRecord(ctx context.Context, domain string, record Record) (*Record, error) { +func (c *Client) AddRecord(ctx context.Context, domain string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("directory", "v1", "org", strconv.FormatInt(c.orgID, 10), "domains", domain, "dns") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) @@ -68,7 +68,7 @@ func (c Client) AddRecord(ctx context.Context, domain string, record Record) (*R // DeleteRecord Deletes a DNS record. // DELETE https://api360.yandex.net/directory/v1/org/{orgId}/domains/{domain}/dns/{recordId} // https://yandex.ru/dev/api360/doc/ref/DomainDNSService/DomainDNSService_Delete.html -func (c Client) DeleteRecord(ctx context.Context, domain string, recordID int64) error { +func (c *Client) DeleteRecord(ctx context.Context, domain string, recordID int64) error { endpoint := c.baseURL.JoinPath("directory", "v1", "org", strconv.FormatInt(c.orgID, 10), "domains", domain, "dns", strconv.FormatInt(recordID, 10)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) @@ -79,7 +79,7 @@ func (c Client) DeleteRecord(ctx context.Context, domain string, recordID int64) return c.do(req, nil) } -func (c Client) do(req *http.Request, result any) error { +func (c *Client) do(req *http.Request, result any) error { req.Header.Set("Authorization", "OAuth "+c.oauthToken) resp, err := c.HTTPClient.Do(req) @@ -138,6 +138,7 @@ func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var apiErr APIError + err := json.Unmarshal(raw, &apiErr) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) diff --git a/providers/dns/yandex360/internal/client_test.go b/providers/dns/yandex360/internal/client_test.go index d0ddac0c3..aa21672e4 100644 --- a/providers/dns/yandex360/internal/client_test.go +++ b/providers/dns/yandex360/internal/client_test.go @@ -1,60 +1,39 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern, method string, status int, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret", 123456) + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - open, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient("secret", 123456) - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("OAuth secret")) } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "/directory/v1/org/123456/domains/example.com/dns", http.MethodPost, http.StatusOK, "add-record.json") + client := mockBuilder(). + Route("POST /directory/v1/org/123456/domains/example.com/dns", + servermock.ResponseFromFixture("add-record.json"), + servermock.CheckRequestJSONBody(`{"name":"_acme-challenge","text":"txtxtxt","ttl":60,"type":"TXT"}`)). + Build(t) record := Record{ Name: "_acme-challenge", @@ -63,7 +42,7 @@ func TestClient_AddRecord(t *testing.T) { Type: "TXT", } - newRecord, err := client.AddRecord(context.Background(), "example.com", record) + newRecord, err := client.AddRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &Record{ @@ -78,7 +57,11 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "/directory/v1/org/123456/domains/example.com/dns", http.MethodGet, http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("POST /directory/v1/org/123456/domains/example.com/dns", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) record := Record{ Name: "_acme-challenge", @@ -87,22 +70,29 @@ func TestClient_AddRecord_error(t *testing.T) { Type: "TXT", } - newRecord, err := client.AddRecord(context.Background(), "example.com", record) + newRecord, err := client.AddRecord(t.Context(), "example.com", record) require.Error(t, err) assert.Nil(t, newRecord) } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "/directory/v1/org/123456/domains/example.com/dns/789456", http.MethodDelete, http.StatusOK, "delete-record.json") + client := mockBuilder(). + Route("DELETE /directory/v1/org/123456/domains/example.com/dns/789456", + servermock.ResponseFromFixture("delete-record.json")). + Build(t) - err := client.DeleteRecord(context.Background(), "example.com", 789456) + err := client.DeleteRecord(t.Context(), "example.com", 789456) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "/directory/v1/org/123456/domains/example.com/dns/789456", http.MethodDelete, http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("DELETE /directory/v1/org/123456/domains/example.com/dns/789456", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) - err := client.DeleteRecord(context.Background(), "example.com", 789456) + err := client.DeleteRecord(t.Context(), "example.com", 789456) require.Error(t, err) } diff --git a/providers/dns/yandex360/yandex360.go b/providers/dns/yandex360/yandex360.go index 38aa835d8..0f4571750 100644 --- a/providers/dns/yandex360/yandex360.go +++ b/providers/dns/yandex360/yandex360.go @@ -10,9 +10,12 @@ import ( "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/yandex360/internal" + "github.com/miekg/dns" ) // Environment variables names. @@ -28,6 +31,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { OAuthToken string @@ -94,6 +99,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + return &DNSProvider{ client: client, config: config, @@ -105,7 +112,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN)) + authZone, err := dns01.FindZoneByFqdn(dns.Fqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("yandex360: could not find zone for domain %q: %w", domain, err) } @@ -140,7 +147,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN)) + authZone, err := dns01.FindZoneByFqdn(dns.Fqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("yandex360: could not find zone for domain %q: %w", domain, err) } diff --git a/providers/dns/yandex360/yandex360.toml b/providers/dns/yandex360/yandex360.toml index 88e4036ab..444b1cc38 100644 --- a/providers/dns/yandex360/yandex360.toml +++ b/providers/dns/yandex360/yandex360.toml @@ -8,7 +8,7 @@ Since = "v4.14.0" Example = ''' YANDEX360_OAUTH_TOKEN= \ YANDEX360_ORG_ID= \ -lego --email you@example.com --dns yandex360 -d '*.example.com' -d example.com run +lego --dns yandex360 -d '*.example.com' -d example.com run ''' [Configuration] @@ -16,10 +16,10 @@ lego --email you@example.com --dns yandex360 -d '*.example.com' -d example.com r YANDEX360_OAUTH_TOKEN = "The OAuth Token" YANDEX360_ORG_ID = "The organization ID" [Configuration.Additional] - YANDEX360_POLLING_INTERVAL = "Time between DNS propagation check" - YANDEX360_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - YANDEX360_HTTP_TIMEOUT = "API request timeout" - YANDEX360_TTL = "The TTL of the TXT record used for the DNS challenge" + YANDEX360_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + YANDEX360_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + YANDEX360_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600)" + YANDEX360_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://yandex.ru/dev/api360/doc/ref/DomainDNSService.html" diff --git a/providers/dns/yandex360/yandex360_test.go b/providers/dns/yandex360/yandex360_test.go index 545c90985..c1d37ad12 100644 --- a/providers/dns/yandex360/yandex360_test.go +++ b/providers/dns/yandex360/yandex360_test.go @@ -43,6 +43,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -109,6 +110,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -122,6 +124,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/yandexcloud/yandexcloud.go b/providers/dns/yandexcloud/yandexcloud.go index 7a5d0bbed..f9c64def1 100644 --- a/providers/dns/yandexcloud/yandexcloud.go +++ b/providers/dns/yandexcloud/yandexcloud.go @@ -11,11 +11,15 @@ import ( "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" - ycdns "github.com/yandex-cloud/go-genproto/yandex/cloud/dns/v1" - ycsdk "github.com/yandex-cloud/go-sdk" - "github.com/yandex-cloud/go-sdk/iamkey" + ycdnsproto "github.com/yandex-cloud/go-genproto/yandex/cloud/dns/v1" + ycdns "github.com/yandex-cloud/go-sdk/services/dns/v1" + ycsdk "github.com/yandex-cloud/go-sdk/v2" + "github.com/yandex-cloud/go-sdk/v2/credentials" + "github.com/yandex-cloud/go-sdk/v2/pkg/iamkey" + "github.com/yandex-cloud/go-sdk/v2/pkg/options" ) // Environment variables names. @@ -30,6 +34,8 @@ const ( EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { IamToken string @@ -51,7 +57,7 @@ func NewDefaultConfig() *Config { // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { - client *ycsdk.SDK + client ycdns.DnsZoneClient config *Config } @@ -88,19 +94,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, fmt.Errorf("yandexcloud: iam token is malformed: %w", err) } - client, err := ycsdk.Build(context.Background(), ycsdk.Config{Credentials: creds}) + sdk, err := ycsdk.Build(context.Background(), options.WithCredentials(creds)) if err != nil { return nil, errors.New("yandexcloud: unable to build yandex cloud sdk") } return &DNSProvider{ - client: client, + client: ycdns.NewDnsZoneClient(sdk), config: config, }, nil } // Present creates a TXT record to fulfill the dns-01 challenge. -func (r *DNSProvider) Present(domain, _, keyAuth string) error { +func (d *DNSProvider) Present(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) @@ -110,7 +116,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { ctx := context.Background() - zones, err := r.getZones(ctx) + zones, err := d.getZones(ctx) if err != nil { return fmt.Errorf("yandexcloud: %w", err) } @@ -132,7 +138,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { return fmt.Errorf("yandexcloud: %w", err) } - err = r.upsertRecordSetData(ctx, zoneID, subDomain, info.Value) + err = d.upsertRecordSetData(ctx, zoneID, subDomain, info.Value) if err != nil { return fmt.Errorf("yandexcloud: %w", err) } @@ -141,7 +147,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { } // CleanUp removes the TXT record matching the specified parameters. -func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) @@ -151,7 +157,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { ctx := context.Background() - zones, err := r.getZones(ctx) + zones, err := d.getZones(ctx) if err != nil { return fmt.Errorf("yandexcloud: %w", err) } @@ -173,7 +179,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { return fmt.Errorf("yandexcloud: %w", err) } - err = r.removeRecordSetData(ctx, zoneID, subDomain, info.Value) + err = d.removeRecordSetData(ctx, zoneID, subDomain, info.Value) if err != nil { return fmt.Errorf("yandexcloud: %w", err) } @@ -183,17 +189,17 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. -func (r *DNSProvider) Timeout() (timeout, interval time.Duration) { - return r.config.PropagationTimeout, r.config.PollingInterval +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } // getZones retrieves available zones from yandex cloud. -func (r *DNSProvider) getZones(ctx context.Context) ([]*ycdns.DnsZone, error) { - list := &ycdns.ListDnsZonesRequest{ - FolderId: r.config.FolderID, +func (d *DNSProvider) getZones(ctx context.Context) ([]*ycdnsproto.DnsZone, error) { + list := &ycdnsproto.ListDnsZonesRequest{ + FolderId: d.config.FolderID, } - response, err := r.client.DNS().DnsZone().List(ctx, list) + response, err := d.client.List(ctx, list) if err != nil { return nil, errors.New("unable to fetch dns zones") } @@ -201,28 +207,29 @@ func (r *DNSProvider) getZones(ctx context.Context) ([]*ycdns.DnsZone, error) { return response.GetDnsZones(), nil } -func (r *DNSProvider) upsertRecordSetData(ctx context.Context, zoneID, name, value string) error { - get := &ycdns.GetDnsZoneRecordSetRequest{ +func (d *DNSProvider) upsertRecordSetData(ctx context.Context, zoneID, name, value string) error { + get := &ycdnsproto.GetDnsZoneRecordSetRequest{ DnsZoneId: zoneID, Name: name, Type: "TXT", } - exist, err := r.client.DNS().DnsZone().GetRecordSet(ctx, get) + exist, err := d.client.GetRecordSet(ctx, get) if err != nil { if !strings.Contains(err.Error(), "RecordSet not found") { return err } } - record := &ycdns.RecordSet{ + record := &ycdnsproto.RecordSet{ Name: name, Type: "TXT", - Ttl: int64(r.config.TTL), + Ttl: int64(d.config.TTL), Data: []string{}, } - var deletions []*ycdns.RecordSet + var deletions []*ycdnsproto.RecordSet + if exist != nil { record.SetData(append(record.GetData(), exist.GetData()...)) deletions = append(deletions, exist) @@ -234,25 +241,25 @@ func (r *DNSProvider) upsertRecordSetData(ctx context.Context, zoneID, name, val return nil } - update := &ycdns.UpdateRecordSetsRequest{ + update := &ycdnsproto.UpdateRecordSetsRequest{ DnsZoneId: zoneID, Deletions: deletions, - Additions: []*ycdns.RecordSet{record}, + Additions: []*ycdnsproto.RecordSet{record}, } - _, err = r.client.DNS().DnsZone().UpdateRecordSets(ctx, update) + _, err = d.client.UpdateRecordSets(ctx, update) return err } -func (r *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, value string) error { - get := &ycdns.GetDnsZoneRecordSetRequest{ +func (d *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, value string) error { + get := &ycdnsproto.GetDnsZoneRecordSetRequest{ DnsZoneId: zoneID, Name: name, Type: "TXT", } - previousRecord, err := r.client.DNS().DnsZone().GetRecordSet(ctx, get) + previousRecord, err := d.client.GetRecordSet(ctx, get) if err != nil { if strings.Contains(err.Error(), "RecordSet not found") { // RecordSet is not present, nothing to do @@ -262,14 +269,14 @@ func (r *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, val return err } - var additions []*ycdns.RecordSet + var additions []*ycdnsproto.RecordSet if len(previousRecord.GetData()) > 1 { // RecordSet is not empty we should update it - record := &ycdns.RecordSet{ + record := &ycdnsproto.RecordSet{ Name: name, Type: "TXT", - Ttl: int64(r.config.TTL), + Ttl: int64(d.config.TTL), Data: []string{}, } @@ -282,34 +289,35 @@ func (r *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, val additions = append(additions, record) } - update := &ycdns.UpdateRecordSetsRequest{ + update := &ycdnsproto.UpdateRecordSetsRequest{ DnsZoneId: zoneID, - Deletions: []*ycdns.RecordSet{previousRecord}, + Deletions: []*ycdnsproto.RecordSet{previousRecord}, Additions: additions, } - _, err = r.client.DNS().DnsZone().UpdateRecordSets(ctx, update) + _, err = d.client.UpdateRecordSets(ctx, update) return err } // decodeCredentials converts base64 encoded json of iam token to struct. -func decodeCredentials(accountB64 string) (ycsdk.Credentials, error) { +func decodeCredentials(accountB64 string) (credentials.Credentials, error) { account, err := base64.StdEncoding.DecodeString(accountB64) if err != nil { return nil, err } key := &iamkey.Key{} + err = json.Unmarshal(account, key) if err != nil { return nil, err } - return ycsdk.ServiceAccountKey(key) + return credentials.ServiceAccountKey(key) } -func appendRecordSetData(record *ycdns.RecordSet, value string) bool { +func appendRecordSetData(record *ycdnsproto.RecordSet, value string) bool { if slices.Contains(record.GetData(), value) { return false } diff --git a/providers/dns/yandexcloud/yandexcloud.toml b/providers/dns/yandexcloud/yandexcloud.toml index c19b9c1cc..d4b40bb1d 100644 --- a/providers/dns/yandexcloud/yandexcloud.toml +++ b/providers/dns/yandexcloud/yandexcloud.toml @@ -7,7 +7,7 @@ Since = "v4.9.0" Example = ''' YANDEX_CLOUD_IAM_TOKEN= \ YANDEX_CLOUD_FOLDER_ID= \ -lego --email you@example.com --dns yandexcloud -d '*.example.com' -d example.com run +lego --dns yandexcloud -d '*.example.com' -d example.com run # --- @@ -20,7 +20,7 @@ YANDEX_CLOUD_IAM_TOKEN=$(echo '{ \ "private_key": "-----BEGIN PRIVATE KEY----------END PRIVATE KEY-----" \ }' | base64) \ YANDEX_CLOUD_FOLDER_ID= \ -lego --email you@example.com --dns yandexcloud -d '*.example.com' -d example.com run +lego --dns yandexcloud -d '*.example.com' -d example.com run ''' Additional = ''' @@ -40,9 +40,9 @@ cat key.json | base64 YANDEX_CLOUD_IAM_TOKEN = "The base64 encoded json which contains information about iam token of service account with `dns.admin` permissions" YANDEX_CLOUD_FOLDER_ID = "The string id of folder (aka project) in Yandex Cloud" [Configuration.Additional] - YANDEX_CLOUD_POLLING_INTERVAL = "Time between DNS propagation check" - YANDEX_CLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - YANDEX_CLOUD_TTL = "The TTL of the TXT record used for the DNS challenge" + YANDEX_CLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + YANDEX_CLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + YANDEX_CLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" [Links] API = "https://cloud.yandex.com/en/docs/dns/quickstart" diff --git a/providers/dns/yandexcloud/yandexcloud_test.go b/providers/dns/yandexcloud/yandexcloud_test.go index 48f75d134..52dad574d 100644 --- a/providers/dns/yandexcloud/yandexcloud_test.go +++ b/providers/dns/yandexcloud/yandexcloud_test.go @@ -71,6 +71,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -143,6 +144,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -156,6 +158,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/zoneedit/internal/client.go b/providers/dns/zoneedit/internal/client.go new file mode 100644 index 000000000..c8b99e173 --- /dev/null +++ b/providers/dns/zoneedit/internal/client.go @@ -0,0 +1,108 @@ +package internal + +import ( + "bytes" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const defaultBaseURL = "https://dynamic.zoneedit.com" + +// Client the ZoneEdit API client. +type Client struct { + user string + authToken string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(user, authToken string) (*Client, error) { + if user == "" || authToken == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + user: user, + authToken: authToken, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) CreateTXTRecord(domain, rdata string) error { + return c.perform("txt-create.php", domain, rdata) +} + +func (c *Client) DeleteTXTRecord(domain, rdata string) error { + return c.perform("txt-delete.php", domain, rdata) +} + +func (c *Client) perform(actionPath, domain, rdata string) error { + endpoint := c.baseURL.JoinPath(actionPath) + + query := endpoint.Query() + query.Set("host", domain) + query.Set("rdata", rdata) + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequest(http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return err + } + + return c.do(req) +} + +func (c *Client) do(req *http.Request) error { + req.SetBasicAuth(c.user, c.authToken) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + raw, _ := io.ReadAll(resp.Body) + + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + if bytes.Contains(raw, []byte("SUCCESS CODE")) { + return nil + } + + raw = bytes.TrimSpace(raw) + + // The answer is not an XML valid (missing closing), so I fix it to parse it. + if bytes.HasSuffix(raw, []byte(">")) { + raw = slices.Concat(raw[:len(raw)-1], []byte("/>")) + } + + var apiErr APIError + + err = xml.Unmarshal(raw, &apiErr) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return fmt.Errorf("[status code: %d] %w", resp.StatusCode, apiErr) +} diff --git a/providers/dns/zoneedit/internal/client_test.go b/providers/dns/zoneedit/internal/client_test.go new file mode 100644 index 000000000..1d9f9be79 --- /dev/null +++ b/providers/dns/zoneedit/internal/client_test.go @@ -0,0 +1,64 @@ +package internal + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder(func(server *httptest.Server) (*Client, error) { + client, err := NewClient("user", "secret") + if err != nil { + return nil, err + } + + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }) +} + +func TestClient_CreateTXTRecord(t *testing.T) { + client := mockBuilder(). + Route("GET /txt-create.php", + servermock.ResponseFromFixture("success.xml")). + Build(t) + + err := client.CreateTXTRecord("_acme-challenge.example.com", "value") + require.NoError(t, err) +} + +func TestClient_CreateTXTRecord_error(t *testing.T) { + client := mockBuilder(). + Route("GET /txt-create.php", + servermock.ResponseFromFixture("error.xml")). + Build(t) + + err := client.CreateTXTRecord("_acme-challenge.example.com", "value") + require.EqualError(t, err, "[status code: 200] 708: Failed Login: user (_acme-challenge.example.com)") +} + +func TestClient_DeleteTXTRecord(t *testing.T) { + client := mockBuilder(). + Route("GET /txt-delete.php", + servermock.ResponseFromFixture("success.xml")). + Build(t) + + err := client.DeleteTXTRecord("_acme-challenge.example.com", "value") + require.NoError(t, err) +} + +func TestClient_DeleteTXTRecord_error(t *testing.T) { + client := mockBuilder(). + Route("GET /txt-delete.php", + servermock.ResponseFromFixture("error.xml")). + Build(t) + + err := client.DeleteTXTRecord("_acme-challenge.example.com", "value") + require.EqualError(t, err, "[status code: 200] 708: Failed Login: user (_acme-challenge.example.com)") +} diff --git a/providers/dns/zoneedit/internal/fixtures/error.xml b/providers/dns/zoneedit/internal/fixtures/error.xml new file mode 100644 index 000000000..6c0f1de60 --- /dev/null +++ b/providers/dns/zoneedit/internal/fixtures/error.xml @@ -0,0 +1 @@ + diff --git a/providers/dns/zoneedit/internal/fixtures/success.xml b/providers/dns/zoneedit/internal/fixtures/success.xml new file mode 100644 index 000000000..80d75169d --- /dev/null +++ b/providers/dns/zoneedit/internal/fixtures/success.xml @@ -0,0 +1 @@ + diff --git a/providers/dns/zoneedit/internal/types.go b/providers/dns/zoneedit/internal/types.go new file mode 100644 index 000000000..96fa41c36 --- /dev/null +++ b/providers/dns/zoneedit/internal/types.go @@ -0,0 +1,18 @@ +package internal + +import ( + "encoding/xml" + "fmt" +) + +type APIError struct { + XMLName xml.Name `xml:"ERROR"` + Text string `xml:",chardata"` + Code string `xml:"CODE,attr"` + Message string `xml:"TEXT,attr"` + Zone string `xml:"ZONE,attr"` +} + +func (a APIError) Error() string { + return fmt.Sprintf("%s: %s (%s)", a.Code, a.Message, a.Zone) +} diff --git a/providers/dns/zoneedit/zoneedit.go b/providers/dns/zoneedit/zoneedit.go new file mode 100644 index 000000000..c815f975a --- /dev/null +++ b/providers/dns/zoneedit/zoneedit.go @@ -0,0 +1,126 @@ +// Package zoneedit implements a DNS provider for solving the DNS-01 challenge using ZoneEdit. +package zoneedit + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/zoneedit/internal" +) + +// Environment variables names. +const ( + envNamespace = "ZONEEDIT_" + + EnvUser = envNamespace + "USER" + EnAuthToken = envNamespace + "AUTH_TOKEN" + + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + User string + AuthToken string + + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for ZoneEdit. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUser, EnAuthToken) + if err != nil { + return nil, fmt.Errorf("zoneedit: %w", err) + } + + config := NewDefaultConfig() + config.User = values[EnvUser] + config.AuthToken = values[EnAuthToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for ZoneEdit. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("zoneedit: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.User, config.AuthToken) + if err != nil { + return nil, fmt.Errorf("zoneedit: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + err := d.client.CreateTXTRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value) + if err != nil { + return fmt.Errorf("zoneedit: create TXT record: %w", err) + } + + // ERROR CODE="702" TEXT="Minimum 10 seconds between requests" + time.Sleep(11 * time.Second) + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + err := d.client.DeleteTXTRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value) + if err != nil { + return fmt.Errorf("zoneedit: delete TXT record: %w", err) + } + + // ERROR CODE="702" TEXT="Minimum 10 seconds between requests" + time.Sleep(11 * time.Second) + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/providers/dns/zoneedit/zoneedit.toml b/providers/dns/zoneedit/zoneedit.toml new file mode 100644 index 000000000..cdc53b33a --- /dev/null +++ b/providers/dns/zoneedit/zoneedit.toml @@ -0,0 +1,23 @@ +Name = "ZoneEdit" +Description = '''''' +URL = "https://www.zoneedit.com" +Code = "zoneedit" +Since = "v4.25.0" + +Example = ''' +ZONEEDIT_USER="xxxxxxxxxxxxxxxxxxxxx" \ +ZONEEDIT_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns zoneedit -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + ZONEEDIT_USER = "User ID" + ZONEEDIT_AUTH_TOKEN = "Authentication token" + [Configuration.Additional] + ZONEEDIT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + ZONEEDIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + ZONEEDIT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://support.zoneedit.com/en/knowledgebase/article/changes-to-dynamic-dns" diff --git a/providers/dns/zoneedit/zoneedit_test.go b/providers/dns/zoneedit/zoneedit_test.go new file mode 100644 index 000000000..0b251fddf --- /dev/null +++ b/providers/dns/zoneedit/zoneedit_test.go @@ -0,0 +1,146 @@ +package zoneedit + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvUser, EnAuthToken).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvUser: "user", + EnAuthToken: "secret", + }, + }, + { + desc: "missing user ID", + envVars: map[string]string{ + EnvUser: "", + EnAuthToken: "secret", + }, + expected: "zoneedit: some credentials information are missing: ZONEEDIT_USER", + }, + { + desc: "missing auth token", + envVars: map[string]string{ + EnvUser: "user", + EnAuthToken: "", + }, + expected: "zoneedit: some credentials information are missing: ZONEEDIT_AUTH_TOKEN", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "zoneedit: some credentials information are missing: ZONEEDIT_USER,ZONEEDIT_AUTH_TOKEN", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + user string + authToken string + expected string + }{ + { + desc: "success", + user: "user", + authToken: "secret", + }, + { + desc: "missing user ID", + authToken: "secret", + expected: "zoneedit: credentials missing", + }, + { + desc: "missing auth token", + user: "user", + expected: "zoneedit: credentials missing", + }, + { + desc: "missing credentials", + expected: "zoneedit: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.User = test.user + config.AuthToken = test.authToken + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/zoneee/internal/client.go b/providers/dns/zoneee/internal/client.go index e4463b83e..9446cd771 100644 --- a/providers/dns/zoneee/internal/client.go +++ b/providers/dns/zoneee/internal/client.go @@ -26,7 +26,7 @@ type Client struct { } // NewClient creates a new Client. -func NewClient(username string, apiKey string) *Client { +func NewClient(username, apiKey string) *Client { baseURL, _ := url.Parse(DefaultEndpoint) return &Client{ diff --git a/providers/dns/zoneee/internal/client_test.go b/providers/dns/zoneee/internal/client_test.go index 9e53117ac..c2f0e781e 100644 --- a/providers/dns/zoneee/internal/client_test.go +++ b/providers/dns/zoneee/internal/client_test.go @@ -1,65 +1,36 @@ package internal import ( - "context" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.BaseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.BaseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("user", "secret"), + ) } func TestClient_GetTxtRecords(t *testing.T) { - client := setupTest(t, http.MethodGet, "/dns/example.com/txt", http.StatusOK, "get-txt-records.json") + client := mockBuilder(). + Route("GET /dns/example.com/txt", servermock.ResponseFromFixture("get-txt-records.json")). + Build(t) - records, err := client.GetTxtRecords(context.Background(), "example.com") + records, err := client.GetTxtRecords(t.Context(), "example.com") require.NoError(t, err) expected := []TXTRecord{ @@ -70,9 +41,14 @@ func TestClient_GetTxtRecords(t *testing.T) { } func TestClient_AddTxtRecord(t *testing.T) { - client := setupTest(t, http.MethodPost, "/dns/example.com/txt", http.StatusCreated, "create-txt-record.json") + client := mockBuilder(). + Route("POST /dns/example.com/txt", + servermock.ResponseFromFixture("create-txt-record.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBody(`{"name":"prefix.example.com","destination":"server.example.com"}`)). + Build(t) - records, err := client.AddTxtRecord(context.Background(), "example.com", TXTRecord{Name: "prefix.example.com", Destination: "server.example.com"}) + records, err := client.AddTxtRecord(t.Context(), "example.com", TXTRecord{Name: "prefix.example.com", Destination: "server.example.com"}) require.NoError(t, err) expected := []TXTRecord{ @@ -83,8 +59,12 @@ func TestClient_AddTxtRecord(t *testing.T) { } func TestClient_RemoveTxtRecord(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns/example.com/txt/123", http.StatusNoContent, "") + client := mockBuilder(). + Route("DELETE /dns/example.com/txt/123", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) - err := client.RemoveTxtRecord(context.Background(), "example.com", "123") + err := client.RemoveTxtRecord(t.Context(), "example.com", "123") require.NoError(t, err) } diff --git a/providers/dns/zoneee/zoneee.go b/providers/dns/zoneee/zoneee.go index 6a412a2c4..5c34ea1c9 100644 --- a/providers/dns/zoneee/zoneee.go +++ b/providers/dns/zoneee/zoneee.go @@ -9,8 +9,10 @@ import ( "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/zoneee/internal" ) @@ -27,6 +29,8 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + // Config is used to configure the creation of the DNSProvider. type Config struct { Endpoint *url.URL @@ -66,6 +70,7 @@ func NewDNSProvider() (*DNSProvider, error) { } rawEndpoint := env.GetOrDefaultString(EnvEndpoint, internal.DefaultEndpoint) + endpoint, err := url.Parse(rawEndpoint) if err != nil { return nil, fmt.Errorf("zoneee: %w", err) @@ -102,6 +107,9 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + if config.Endpoint != nil { client.BaseURL = config.Endpoint } @@ -135,6 +143,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("zoneee: %w", err) } + return nil } @@ -157,6 +166,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } var id string + for _, record := range records { if record.Destination == info.Value { id = record.ID diff --git a/providers/dns/zoneee/zoneee.toml b/providers/dns/zoneee/zoneee.toml index 5d95095e8..ab7133180 100644 --- a/providers/dns/zoneee/zoneee.toml +++ b/providers/dns/zoneee/zoneee.toml @@ -7,7 +7,7 @@ Since = "v2.1.0" Example = ''' ZONEEE_API_USER=xxxxx \ ZONEEE_API_KEY=yyyyy \ -lego --email you@example.com --dns zoneee -d '*.example.com' -d example.com run +lego --dns zoneee -d '*.example.com' -d example.com run ''' [Configuration] @@ -16,10 +16,9 @@ lego --email you@example.com --dns zoneee -d '*.example.com' -d example.com run ZONEEE_API_KEY = "API key" [Configuration.Additional] ZONEEE_ENDPOINT = "API endpoint URL" - ZONEEE_POLLING_INTERVAL = "Time between DNS propagation check" - ZONEEE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - ZONEEE_TTL = "The TTL of the TXT record used for the DNS challenge" - ZONEEE_HTTP_TIMEOUT = "API request timeout" + ZONEEE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" + ZONEEE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" + ZONEEE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.zone.eu/v2" diff --git a/providers/dns/zoneee/zoneee_test.go b/providers/dns/zoneee/zoneee_test.go index 1f2909fa7..9ad87c02a 100644 --- a/providers/dns/zoneee/zoneee_test.go +++ b/providers/dns/zoneee/zoneee_test.go @@ -6,17 +6,22 @@ import ( "net/http" "net/http/httptest" "net/url" - "path" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/zoneee/internal" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" +const ( + fakeUsername = "user" + fakeAPIKey = "secret" +) + var envTest = tester.NewEnvTest(EnvEndpoint, EnvAPIUser, EnvAPIKey). WithLiveTestRequirements(EnvAPIUser, EnvAPIKey). WithDomain(envDomain) @@ -72,6 +77,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -94,7 +100,6 @@ func TestNewDNSProviderConfig(t *testing.T) { desc string apiUser string apiKey string - endpoint string expected string }{ { @@ -124,10 +129,6 @@ func TestNewDNSProviderConfig(t *testing.T) { config.APIKey = test.apiKey config.Username = test.apiUser - if test.endpoint != "" { - config.Endpoint = mustParse(test.endpoint) - } - p, err := NewDNSProviderConfig(config) if test.expected == "" { @@ -147,57 +148,33 @@ func TestDNSProvider_Present(t *testing.T) { testCases := []struct { desc string - username string - apiKey string - handlers map[string]http.HandlerFunc + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "success", - username: "bar", - apiKey: "foo", - handlers: map[string]http.HandlerFunc{ - path.Join("/", "dns", hostedZone, "txt"): mockHandlerCreateRecord, - }, + desc: "success", + builder: mockBuilder(fakeUsername, fakeAPIKey). + Route("POST /dns/"+hostedZone+"/txt", + mockHandlerCreateRecord()), }, { - desc: "invalid auth", - username: "nope", - apiKey: "foo", - handlers: map[string]http.HandlerFunc{ - path.Join("/", "dns", hostedZone, "txt"): mockHandlerCreateRecord, - }, + desc: "invalid auth", + builder: mockBuilder("nope", "nope"). + Route("POST /dns/"+hostedZone+"/txt", nil), expectedError: "zoneee: unexpected status code: [status code: 401] body: Unauthorized", }, { desc: "error", - username: "bar", - apiKey: "foo", + builder: mockBuilder(fakeUsername, fakeAPIKey), expectedError: "zoneee: unexpected status code: [status code: 404] body: 404 page not found", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - t.Parallel() + provider := test.builder.Build(t) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - for uri, handler := range test.handlers { - mux.HandleFunc(uri, handler) - } - - config := NewDefaultConfig() - config.Endpoint = mustParse(server.URL) - config.Username = test.username - config.APIKey = test.apiKey - - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - err = p.Present(domain, "token", "key") + err := provider.Present(domain, "token", "key") if test.expectedError == "" { require.NoError(t, err) } else { @@ -213,81 +190,49 @@ func TestDNSProvider_Cleanup(t *testing.T) { testCases := []struct { desc string - username string - apiKey string - handlers map[string]http.HandlerFunc + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "success", - username: "bar", - apiKey: "foo", - handlers: map[string]http.HandlerFunc{ - path.Join("/", "dns", hostedZone, "txt"): mockHandlerGetRecords([]internal.TXTRecord{{ - ID: "1234", - Name: domain, - Destination: "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM", - Delete: true, - Modify: true, - }}), - path.Join("/", "dns", hostedZone, "txt", "1234"): mockHandlerDeleteRecord, - }, + desc: "success", + builder: mockBuilder(fakeUsername, fakeAPIKey). + Route("GET /dns/"+hostedZone+"/txt", + mockHandlerGetRecords([]internal.TXTRecord{{ + ID: "1234", + Name: domain, + Destination: "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM", + Delete: true, + Modify: true, + }})). + Route("DELETE /dns/"+hostedZone+"/txt/1234", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)), }, { - desc: "no txt records", - username: "bar", - apiKey: "foo", - handlers: map[string]http.HandlerFunc{ - path.Join("/", "dns", hostedZone, "txt"): mockHandlerGetRecords([]internal.TXTRecord{}), - path.Join("/", "dns", hostedZone, "txt", "1234"): mockHandlerDeleteRecord, - }, + desc: "no txt records", + builder: mockBuilder(fakeUsername, fakeAPIKey). + Route("GET /dns/"+hostedZone+"/txt", + mockHandlerGetRecords([]internal.TXTRecord{})), expectedError: "zoneee: txt record does not exist for LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM", }, { - desc: "invalid auth", - username: "nope", - apiKey: "foo", - handlers: map[string]http.HandlerFunc{ - path.Join("/", "dns", hostedZone, "txt"): mockHandlerGetRecords([]internal.TXTRecord{{ - ID: "1234", - Name: domain, - Destination: "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM", - Delete: true, - Modify: true, - }}), - path.Join("/", "dns", hostedZone, "txt", "1234"): mockHandlerDeleteRecord, - }, + desc: "invalid auth", + builder: mockBuilder("nope", "nope"). + Route("GET /dns/"+hostedZone+"/txt", nil), expectedError: "zoneee: unexpected status code: [status code: 401] body: Unauthorized", }, { desc: "error", - username: "bar", - apiKey: "foo", + builder: mockBuilder(fakeUsername, fakeAPIKey), expectedError: "zoneee: unexpected status code: [status code: 404] body: 404 page not found", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - t.Parallel() + provider := test.builder.Build(t) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - for uri, handler := range test.handlers { - mux.HandleFunc(uri, handler) - } - - config := NewDefaultConfig() - config.Endpoint = mustParse(server.URL) - config.Username = test.username - config.APIKey = test.apiKey - - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - err = p.CleanUp(domain, "token", "key") + err := provider.CleanUp(domain, "token", "key") if test.expectedError == "" { require.NoError(t, err) } else { @@ -303,6 +248,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -316,6 +262,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -325,72 +272,59 @@ func TestLiveCleanUp(t *testing.T) { require.NoError(t, err) } -func mustParse(rawURL string) *url.URL { - uri, err := url.Parse(rawURL) - if err != nil { - panic(err) - } - return uri +func mockBuilder(username, apiKey string) *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.HTTPClient = server.Client() + config.Endpoint, _ = url.Parse(server.URL) + config.Username = username + config.APIKey = apiKey + + return NewDNSProviderConfig(config) + }, + checkBasicAuth()) } -func mockHandlerCreateRecord(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } +func mockHandlerCreateRecord() http.HandlerFunc { + return encodeJSONHandler(func(req *http.Request, rw http.ResponseWriter) (any, error) { + record := internal.TXTRecord{} - username, apiKey, ok := req.BasicAuth() - if username != "bar" || apiKey != "foo" || !ok { - rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and API key.")) - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } + err := json.NewDecoder(req.Body).Decode(&record) + if err != nil { + return nil, err + } - record := internal.TXTRecord{} - err := json.NewDecoder(req.Body).Decode(&record) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } + record.ID = "1234" + record.Delete = true + record.Modify = true + record.ResourceURL = req.URL.String() + "/1234" - record.ID = "1234" - record.Delete = true - record.Modify = true - record.ResourceURL = req.URL.String() + "/1234" - - bytes, err := json.Marshal([]internal.TXTRecord{record}) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if _, err = rw.Write(bytes); err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } + return []internal.TXTRecord{record}, nil + }) } func mockHandlerGetRecords(records []internal.TXTRecord) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - username, apiKey, ok := req.BasicAuth() - if username != "bar" || apiKey != "foo" || !ok { - rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and API key.")) - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - for _, value := range records { - if value.ResourceURL == "" { - value.ResourceURL = req.URL.String() + "/" + value.ID + return encodeJSONHandler(func(req *http.Request, rw http.ResponseWriter) (any, error) { + for _, record := range records { + if record.ResourceURL == "" { + record.ResourceURL = req.URL.String() + "/" + record.ID } } - bytes, err := json.Marshal(records) + return records, nil + }) +} + +func encodeJSONHandler(build func(req *http.Request, rw http.ResponseWriter) (any, error)) http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + data, err := build(req, rw) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + bytes, err := json.Marshal(data) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return @@ -403,18 +337,18 @@ func mockHandlerGetRecords(records []internal.TXTRecord) http.HandlerFunc { } } -func mockHandlerDeleteRecord(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } +func checkBasicAuth() servermock.LinkFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + username, apiKey, ok := req.BasicAuth() + if username != fakeUsername || apiKey != fakeAPIKey || !ok { + rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and API key.")) + http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - username, apiKey, ok := req.BasicAuth() - if username != "bar" || apiKey != "foo" || !ok { - rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and API key.")) - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } + return + } - rw.WriteHeader(http.StatusNoContent) + next.ServeHTTP(rw, req) + }) + } } diff --git a/providers/dns/zonomi/zonomi.go b/providers/dns/zonomi/zonomi.go index 5d1a2c792..fe54b80fc 100644 --- a/providers/dns/zonomi/zonomi.go +++ b/providers/dns/zonomi/zonomi.go @@ -2,12 +2,12 @@ package zonomi 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/rimuhosting" @@ -25,20 +25,17 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) -// Config is used to configure the creation of the DNSProvider. -type Config struct { - APIKey string +const defaultBaseURL = "https://zonomi.com/app/dns/dyndns.jsp" - PropagationTimeout time.Duration - PollingInterval time.Duration - TTL int - HTTPClient *http.Client -} +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +// Config is used to configure the creation of the DNSProvider. +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{ @@ -49,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. @@ -73,48 +69,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("zonomi: the configuration of the DNS provider is nil") } - if config.APIKey == "" { - return nil, errors.New("zonomi: incomplete credentials, missing API key") + provider, err := rimuhosting.NewDNSProviderConfig(config, defaultBaseURL) + if err != nil { + return nil, fmt.Errorf("zonomi: %w", err) } - client := rimuhosting.NewClient(config.APIKey) - client.BaseURL = rimuhosting.DefaultZonomiBaseURL - - if config.HTTPClient != nil { - client.HTTPClient = config.HTTPClient - } - - return &DNSProvider{config: config, client: client}, nil -} - -// Timeout returns the timeout and interval to use when checking for DNS propagation. -// Adjusting here to cope with spikes in propagation times. -func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.config.PropagationTimeout, d.config.PollingInterval + return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - ctx := context.Background() - - records, err := d.client.FindTXTRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN)) + err := d.prv.Present(domain, token, keyAuth) if err != nil { - return fmt.Errorf("zonomi: failed to find record(s) for %s: %w", domain, err) - } - - actions := []rimuhosting.ActionParameter{ - rimuhosting.NewAddRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL), - } - - for _, record := range records { - actions = append(actions, rimuhosting.NewAddRecordAction(record.Name, record.Content, d.config.TTL)) - } - - _, err = d.client.DoActions(ctx, actions...) - if err != nil { - return fmt.Errorf("zonomi: failed to add record(s) for %s: %w", domain, err) + return fmt.Errorf("zonomi: %w", err) } return nil @@ -122,14 +89,16 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - action := rimuhosting.NewDeleteRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value) - - _, err := d.client.DoActions(context.Background(), action) + err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { - return fmt.Errorf("zonomi: failed to delete record for %s: %w", domain, err) + return fmt.Errorf("zonomi: %w", err) } return nil } + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.prv.Timeout() +} diff --git a/providers/dns/zonomi/zonomi.toml b/providers/dns/zonomi/zonomi.toml index 9780323a7..b91bcaac6 100644 --- a/providers/dns/zonomi/zonomi.toml +++ b/providers/dns/zonomi/zonomi.toml @@ -6,17 +6,17 @@ Since = "v3.5.0" Example = ''' ZONOMI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ -lego --email you@example.com --dns zonomi -d '*.example.com' -d example.com run +lego --dns zonomi -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ZONOMI_API_KEY = "User API key" [Configuration.Additional] - ZONOMI_POLLING_INTERVAL = "Time between DNS propagation check" - ZONOMI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - ZONOMI_TTL = "The TTL of the TXT record used for the DNS challenge" - ZONOMI_HTTP_TIMEOUT = "API request timeout" + ZONOMI_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + ZONOMI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + ZONOMI_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" + ZONOMI_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://zonomi.com/app/dns/dyndns.jsp" diff --git a/providers/dns/zonomi/zonomi_test.go b/providers/dns/zonomi/zonomi_test.go index fb1b68773..2e13e937e 100644 --- a/providers/dns/zonomi/zonomi_test.go +++ b/providers/dns/zonomi/zonomi_test.go @@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() + envTest.ClearEnv() envTest.Apply(test.envVars) @@ -45,7 +46,7 @@ func TestNewDNSProvider(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - require.NotNil(t, p.config) + require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -83,7 +84,7 @@ func TestNewDNSProviderConfig(t *testing.T) { if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) - require.NotNil(t, p.config) + require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } @@ -97,6 +98,7 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) @@ -110,6 +112,7 @@ func TestLiveCleanUp(t *testing.T) { } envTest.RestoreEnv() + provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/dns/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go index 3d9f4965d..9c4bc9e61 100644 --- a/providers/dns/zz_gen_dns_providers.go +++ b/providers/dns/zz_gen_dns_providers.go @@ -6,17 +6,28 @@ import ( "fmt" "github.com/go-acme/lego/v4/challenge" - "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/acmedns" + "github.com/go-acme/lego/v4/providers/dns/active24" "github.com/go-acme/lego/v4/providers/dns/alidns" + "github.com/go-acme/lego/v4/providers/dns/aliesa" "github.com/go-acme/lego/v4/providers/dns/allinkl" + "github.com/go-acme/lego/v4/providers/dns/alwaysdata" + "github.com/go-acme/lego/v4/providers/dns/anexia" + "github.com/go-acme/lego/v4/providers/dns/artfiles" "github.com/go-acme/lego/v4/providers/dns/arvancloud" "github.com/go-acme/lego/v4/providers/dns/auroradns" "github.com/go-acme/lego/v4/providers/dns/autodns" + "github.com/go-acme/lego/v4/providers/dns/axelname" + "github.com/go-acme/lego/v4/providers/dns/azion" "github.com/go-acme/lego/v4/providers/dns/azure" "github.com/go-acme/lego/v4/providers/dns/azuredns" + "github.com/go-acme/lego/v4/providers/dns/baiducloud" + "github.com/go-acme/lego/v4/providers/dns/beget" + "github.com/go-acme/lego/v4/providers/dns/binarylane" "github.com/go-acme/lego/v4/providers/dns/bindman" "github.com/go-acme/lego/v4/providers/dns/bluecat" + "github.com/go-acme/lego/v4/providers/dns/bluecatv2" + "github.com/go-acme/lego/v4/providers/dns/bookmyname" "github.com/go-acme/lego/v4/providers/dns/brandit" "github.com/go-acme/lego/v4/providers/dns/bunny" "github.com/go-acme/lego/v4/providers/dns/checkdomain" @@ -26,15 +37,20 @@ import ( "github.com/go-acme/lego/v4/providers/dns/cloudns" "github.com/go-acme/lego/v4/providers/dns/cloudru" "github.com/go-acme/lego/v4/providers/dns/cloudxns" + "github.com/go-acme/lego/v4/providers/dns/com35" "github.com/go-acme/lego/v4/providers/dns/conoha" + "github.com/go-acme/lego/v4/providers/dns/conohav3" "github.com/go-acme/lego/v4/providers/dns/constellix" "github.com/go-acme/lego/v4/providers/dns/corenetworks" "github.com/go-acme/lego/v4/providers/dns/cpanel" + "github.com/go-acme/lego/v4/providers/dns/czechia" + "github.com/go-acme/lego/v4/providers/dns/ddnss" "github.com/go-acme/lego/v4/providers/dns/derak" "github.com/go-acme/lego/v4/providers/dns/desec" "github.com/go-acme/lego/v4/providers/dns/designate" "github.com/go-acme/lego/v4/providers/dns/digitalocean" "github.com/go-acme/lego/v4/providers/dns/directadmin" + "github.com/go-acme/lego/v4/providers/dns/dnsexit" "github.com/go-acme/lego/v4/providers/dns/dnshomede" "github.com/go-acme/lego/v4/providers/dns/dnsimple" "github.com/go-acme/lego/v4/providers/dns/dnsmadeeasy" @@ -44,23 +60,33 @@ import ( "github.com/go-acme/lego/v4/providers/dns/dreamhost" "github.com/go-acme/lego/v4/providers/dns/duckdns" "github.com/go-acme/lego/v4/providers/dns/dyn" + "github.com/go-acme/lego/v4/providers/dns/dyndnsfree" "github.com/go-acme/lego/v4/providers/dns/dynu" "github.com/go-acme/lego/v4/providers/dns/easydns" + "github.com/go-acme/lego/v4/providers/dns/edgecenter" "github.com/go-acme/lego/v4/providers/dns/edgedns" + "github.com/go-acme/lego/v4/providers/dns/edgeone" "github.com/go-acme/lego/v4/providers/dns/efficientip" "github.com/go-acme/lego/v4/providers/dns/epik" + "github.com/go-acme/lego/v4/providers/dns/eurodns" + "github.com/go-acme/lego/v4/providers/dns/excedo" "github.com/go-acme/lego/v4/providers/dns/exec" "github.com/go-acme/lego/v4/providers/dns/exoscale" + "github.com/go-acme/lego/v4/providers/dns/f5xc" "github.com/go-acme/lego/v4/providers/dns/freemyip" "github.com/go-acme/lego/v4/providers/dns/gandi" "github.com/go-acme/lego/v4/providers/dns/gandiv5" "github.com/go-acme/lego/v4/providers/dns/gcloud" "github.com/go-acme/lego/v4/providers/dns/gcore" + "github.com/go-acme/lego/v4/providers/dns/gigahostno" "github.com/go-acme/lego/v4/providers/dns/glesys" "github.com/go-acme/lego/v4/providers/dns/godaddy" "github.com/go-acme/lego/v4/providers/dns/googledomains" + "github.com/go-acme/lego/v4/providers/dns/gravity" "github.com/go-acme/lego/v4/providers/dns/hetzner" "github.com/go-acme/lego/v4/providers/dns/hostingde" + "github.com/go-acme/lego/v4/providers/dns/hostinger" + "github.com/go-acme/lego/v4/providers/dns/hostingnl" "github.com/go-acme/lego/v4/providers/dns/hosttech" "github.com/go-acme/lego/v4/providers/dns/httpnet" "github.com/go-acme/lego/v4/providers/dns/httpreq" @@ -75,9 +101,15 @@ import ( "github.com/go-acme/lego/v4/providers/dns/internetbs" "github.com/go-acme/lego/v4/providers/dns/inwx" "github.com/go-acme/lego/v4/providers/dns/ionos" + "github.com/go-acme/lego/v4/providers/dns/ionoscloud" "github.com/go-acme/lego/v4/providers/dns/ipv64" + "github.com/go-acme/lego/v4/providers/dns/ispconfig" + "github.com/go-acme/lego/v4/providers/dns/ispconfigddns" "github.com/go-acme/lego/v4/providers/dns/iwantmyname" + "github.com/go-acme/lego/v4/providers/dns/jdcloud" "github.com/go-acme/lego/v4/providers/dns/joker" + "github.com/go-acme/lego/v4/providers/dns/keyhelp" + "github.com/go-acme/lego/v4/providers/dns/leaseweb" "github.com/go-acme/lego/v4/providers/dns/liara" "github.com/go-acme/lego/v4/providers/dns/lightsail" "github.com/go-acme/lego/v4/providers/dns/limacity" @@ -86,22 +118,30 @@ import ( "github.com/go-acme/lego/v4/providers/dns/loopia" "github.com/go-acme/lego/v4/providers/dns/luadns" "github.com/go-acme/lego/v4/providers/dns/mailinabox" + "github.com/go-acme/lego/v4/providers/dns/manageengine" + "github.com/go-acme/lego/v4/providers/dns/manual" "github.com/go-acme/lego/v4/providers/dns/metaname" + "github.com/go-acme/lego/v4/providers/dns/metaregistrar" "github.com/go-acme/lego/v4/providers/dns/mijnhost" "github.com/go-acme/lego/v4/providers/dns/mittwald" + "github.com/go-acme/lego/v4/providers/dns/myaddr" "github.com/go-acme/lego/v4/providers/dns/mydnsjp" "github.com/go-acme/lego/v4/providers/dns/mythicbeasts" "github.com/go-acme/lego/v4/providers/dns/namecheap" "github.com/go-acme/lego/v4/providers/dns/namedotcom" "github.com/go-acme/lego/v4/providers/dns/namesilo" + "github.com/go-acme/lego/v4/providers/dns/namesurfer" "github.com/go-acme/lego/v4/providers/dns/nearlyfreespeech" + "github.com/go-acme/lego/v4/providers/dns/neodigit" "github.com/go-acme/lego/v4/providers/dns/netcup" "github.com/go-acme/lego/v4/providers/dns/netlify" "github.com/go-acme/lego/v4/providers/dns/nicmanager" + "github.com/go-acme/lego/v4/providers/dns/nicru" "github.com/go-acme/lego/v4/providers/dns/nifcloud" "github.com/go-acme/lego/v4/providers/dns/njalla" "github.com/go-acme/lego/v4/providers/dns/nodion" "github.com/go-acme/lego/v4/providers/dns/ns1" + "github.com/go-acme/lego/v4/providers/dns/octenium" "github.com/go-acme/lego/v4/providers/dns/oraclecloud" "github.com/go-acme/lego/v4/providers/dns/otc" "github.com/go-acme/lego/v4/providers/dns/ovh" @@ -109,6 +149,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/plesk" "github.com/go-acme/lego/v4/providers/dns/porkbun" "github.com/go-acme/lego/v4/providers/dns/rackspace" + "github.com/go-acme/lego/v4/providers/dns/rainyun" "github.com/go-acme/lego/v4/providers/dns/rcodezero" "github.com/go-acme/lego/v4/providers/dns/regfish" "github.com/go-acme/lego/v4/providers/dns/regru" @@ -125,27 +166,35 @@ import ( "github.com/go-acme/lego/v4/providers/dns/shellrent" "github.com/go-acme/lego/v4/providers/dns/simply" "github.com/go-acme/lego/v4/providers/dns/sonic" + "github.com/go-acme/lego/v4/providers/dns/spaceship" "github.com/go-acme/lego/v4/providers/dns/stackpath" + "github.com/go-acme/lego/v4/providers/dns/syse" "github.com/go-acme/lego/v4/providers/dns/technitium" "github.com/go-acme/lego/v4/providers/dns/tencentcloud" "github.com/go-acme/lego/v4/providers/dns/timewebcloud" + "github.com/go-acme/lego/v4/providers/dns/todaynic" "github.com/go-acme/lego/v4/providers/dns/transip" "github.com/go-acme/lego/v4/providers/dns/ultradns" + "github.com/go-acme/lego/v4/providers/dns/uniteddomains" "github.com/go-acme/lego/v4/providers/dns/variomedia" "github.com/go-acme/lego/v4/providers/dns/vegadns" "github.com/go-acme/lego/v4/providers/dns/vercel" "github.com/go-acme/lego/v4/providers/dns/versio" "github.com/go-acme/lego/v4/providers/dns/vinyldns" + "github.com/go-acme/lego/v4/providers/dns/virtualname" "github.com/go-acme/lego/v4/providers/dns/vkcloud" "github.com/go-acme/lego/v4/providers/dns/volcengine" "github.com/go-acme/lego/v4/providers/dns/vscale" "github.com/go-acme/lego/v4/providers/dns/vultr" "github.com/go-acme/lego/v4/providers/dns/webnames" + "github.com/go-acme/lego/v4/providers/dns/webnamesca" "github.com/go-acme/lego/v4/providers/dns/websupport" "github.com/go-acme/lego/v4/providers/dns/wedos" + "github.com/go-acme/lego/v4/providers/dns/westcn" "github.com/go-acme/lego/v4/providers/dns/yandex" "github.com/go-acme/lego/v4/providers/dns/yandex360" "github.com/go-acme/lego/v4/providers/dns/yandexcloud" + "github.com/go-acme/lego/v4/providers/dns/zoneedit" "github.com/go-acme/lego/v4/providers/dns/zoneee" "github.com/go-acme/lego/v4/providers/dns/zonomi" ) @@ -153,28 +202,50 @@ import ( // NewDNSChallengeProviderByName Factory for DNS providers. func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { switch name { - case "manual": - return dns01.NewDNSProviderManual() case "acme-dns", "acmedns": return acmedns.NewDNSProvider() + case "active24": + return active24.NewDNSProvider() case "alidns": return alidns.NewDNSProvider() + case "aliesa": + return aliesa.NewDNSProvider() case "allinkl": return allinkl.NewDNSProvider() + case "alwaysdata": + return alwaysdata.NewDNSProvider() + case "anexia": + return anexia.NewDNSProvider() + case "artfiles": + return artfiles.NewDNSProvider() case "arvancloud": return arvancloud.NewDNSProvider() case "auroradns": return auroradns.NewDNSProvider() case "autodns": return autodns.NewDNSProvider() + case "axelname": + return axelname.NewDNSProvider() + case "azion": + return azion.NewDNSProvider() case "azure": return azure.NewDNSProvider() case "azuredns": return azuredns.NewDNSProvider() + case "baiducloud": + return baiducloud.NewDNSProvider() + case "beget": + return beget.NewDNSProvider() + case "binarylane": + return binarylane.NewDNSProvider() case "bindman": return bindman.NewDNSProvider() case "bluecat": return bluecat.NewDNSProvider() + case "bluecatv2": + return bluecatv2.NewDNSProvider() + case "bookmyname": + return bookmyname.NewDNSProvider() case "brandit": return brandit.NewDNSProvider() case "bunny": @@ -193,14 +264,22 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return cloudru.NewDNSProvider() case "cloudxns": return cloudxns.NewDNSProvider() + case "com35": + return com35.NewDNSProvider() case "conoha": return conoha.NewDNSProvider() + case "conohav3": + return conohav3.NewDNSProvider() case "constellix": return constellix.NewDNSProvider() case "corenetworks": return corenetworks.NewDNSProvider() case "cpanel": return cpanel.NewDNSProvider() + case "czechia": + return czechia.NewDNSProvider() + case "ddnss": + return ddnss.NewDNSProvider() case "derak": return derak.NewDNSProvider() case "desec": @@ -211,6 +290,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return digitalocean.NewDNSProvider() case "directadmin": return directadmin.NewDNSProvider() + case "dnsexit": + return dnsexit.NewDNSProvider() case "dnshomede": return dnshomede.NewDNSProvider() case "dnsimple": @@ -229,20 +310,32 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return duckdns.NewDNSProvider() case "dyn": return dyn.NewDNSProvider() + case "dyndnsfree": + return dyndnsfree.NewDNSProvider() case "dynu": return dynu.NewDNSProvider() case "easydns": return easydns.NewDNSProvider() + case "edgecenter": + return edgecenter.NewDNSProvider() case "edgedns", "fastdns": return edgedns.NewDNSProvider() + case "edgeone": + return edgeone.NewDNSProvider() case "efficientip": return efficientip.NewDNSProvider() case "epik": return epik.NewDNSProvider() + case "eurodns": + return eurodns.NewDNSProvider() + case "excedo": + return excedo.NewDNSProvider() case "exec": return exec.NewDNSProvider() case "exoscale": return exoscale.NewDNSProvider() + case "f5xc": + return f5xc.NewDNSProvider() case "freemyip": return freemyip.NewDNSProvider() case "gandi": @@ -253,16 +346,24 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return gcloud.NewDNSProvider() case "gcore": return gcore.NewDNSProvider() + case "gigahostno": + return gigahostno.NewDNSProvider() case "glesys": return glesys.NewDNSProvider() case "godaddy": return godaddy.NewDNSProvider() case "googledomains": return googledomains.NewDNSProvider() + case "gravity": + return gravity.NewDNSProvider() case "hetzner": return hetzner.NewDNSProvider() case "hostingde": return hostingde.NewDNSProvider() + case "hostinger": + return hostinger.NewDNSProvider() + case "hostingnl": + return hostingnl.NewDNSProvider() case "hosttech": return hosttech.NewDNSProvider() case "httpnet": @@ -291,12 +392,24 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return inwx.NewDNSProvider() case "ionos": return ionos.NewDNSProvider() + case "ionoscloud": + return ionoscloud.NewDNSProvider() case "ipv64": return ipv64.NewDNSProvider() + case "ispconfig": + return ispconfig.NewDNSProvider() + case "ispconfigddns": + return ispconfigddns.NewDNSProvider() case "iwantmyname": return iwantmyname.NewDNSProvider() + case "jdcloud": + return jdcloud.NewDNSProvider() case "joker": return joker.NewDNSProvider() + case "keyhelp": + return keyhelp.NewDNSProvider() + case "leaseweb": + return leaseweb.NewDNSProvider() case "liara": return liara.NewDNSProvider() case "lightsail": @@ -313,12 +426,20 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return luadns.NewDNSProvider() case "mailinabox": return mailinabox.NewDNSProvider() + case "manageengine": + return manageengine.NewDNSProvider() + case "manual": + return manual.NewDNSProvider() case "metaname": return metaname.NewDNSProvider() + case "metaregistrar": + return metaregistrar.NewDNSProvider() case "mijnhost": return mijnhost.NewDNSProvider() case "mittwald": return mittwald.NewDNSProvider() + case "myaddr": + return myaddr.NewDNSProvider() case "mydnsjp": return mydnsjp.NewDNSProvider() case "mythicbeasts": @@ -329,14 +450,20 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return namedotcom.NewDNSProvider() case "namesilo": return namesilo.NewDNSProvider() + case "namesurfer": + return namesurfer.NewDNSProvider() case "nearlyfreespeech": return nearlyfreespeech.NewDNSProvider() + case "neodigit": + return neodigit.NewDNSProvider() case "netcup": return netcup.NewDNSProvider() case "netlify": return netlify.NewDNSProvider() case "nicmanager": return nicmanager.NewDNSProvider() + case "nicru": + return nicru.NewDNSProvider() case "nifcloud": return nifcloud.NewDNSProvider() case "njalla": @@ -345,6 +472,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return nodion.NewDNSProvider() case "ns1": return ns1.NewDNSProvider() + case "octenium": + return octenium.NewDNSProvider() case "oraclecloud": return oraclecloud.NewDNSProvider() case "otc": @@ -359,6 +488,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return porkbun.NewDNSProvider() case "rackspace": return rackspace.NewDNSProvider() + case "rainyun": + return rainyun.NewDNSProvider() case "rcodezero": return rcodezero.NewDNSProvider() case "regfish": @@ -391,18 +522,26 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return simply.NewDNSProvider() case "sonic": return sonic.NewDNSProvider() + case "spaceship": + return spaceship.NewDNSProvider() case "stackpath": return stackpath.NewDNSProvider() + case "syse": + return syse.NewDNSProvider() case "technitium": return technitium.NewDNSProvider() case "tencentcloud": return tencentcloud.NewDNSProvider() case "timewebcloud": return timewebcloud.NewDNSProvider() + case "todaynic": + return todaynic.NewDNSProvider() case "transip": return transip.NewDNSProvider() case "ultradns": return ultradns.NewDNSProvider() + case "uniteddomains": + return uniteddomains.NewDNSProvider() case "variomedia": return variomedia.NewDNSProvider() case "vegadns": @@ -413,6 +552,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return versio.NewDNSProvider() case "vinyldns": return vinyldns.NewDNSProvider() + case "virtualname": + return virtualname.NewDNSProvider() case "vkcloud": return vkcloud.NewDNSProvider() case "volcengine": @@ -421,18 +562,24 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return vscale.NewDNSProvider() case "vultr": return vultr.NewDNSProvider() - case "webnames": + case "webnames", "webnamesru": return webnames.NewDNSProvider() + case "webnamesca": + return webnamesca.NewDNSProvider() case "websupport": return websupport.NewDNSProvider() case "wedos": return wedos.NewDNSProvider() + case "westcn": + return westcn.NewDNSProvider() case "yandex": return yandex.NewDNSProvider() case "yandex360": return yandex360.NewDNSProvider() case "yandexcloud": return yandexcloud.NewDNSProvider() + case "zoneedit": + return zoneedit.NewDNSProvider() case "zoneee": return zoneee.NewDNSProvider() case "zonomi": diff --git a/providers/http/memcached/memcached.go b/providers/http/memcached/memcached.go index b26def2c4..376ae8c16 100644 --- a/providers/http/memcached/memcached.go +++ b/providers/http/memcached/memcached.go @@ -33,12 +33,14 @@ func (w *HTTPProvider) Present(domain, token, keyAuth string) error { var errs []error challengePath := path.Join("/", http01.ChallengePath(token)) + for _, host := range w.hosts { mc, err := memcache.New(host) if err != nil { errs = append(errs, err) continue } + _ = mc.Add(&memcache.Item{ Key: challengePath, Value: []byte(keyAuth), diff --git a/providers/http/memcached/memcached_test.go b/providers/http/memcached/memcached_test.go index fb450f988..5862efbc6 100644 --- a/providers/http/memcached/memcached_test.go +++ b/providers/http/memcached/memcached_test.go @@ -25,6 +25,7 @@ func loadMemcachedHosts() []string { if memcachedHostsStr != "" { return strings.Split(memcachedHostsStr, ",") } + return nil } @@ -38,6 +39,7 @@ func TestNewMemcachedProviderValid(t *testing.T) { if len(memcachedHosts) == 0 { t.Skip("Skipping memcached tests") } + _, err := NewMemcachedProvider(memcachedHosts) require.NoError(t, err) } @@ -46,6 +48,7 @@ func TestMemcachedPresentSingleHost(t *testing.T) { if len(memcachedHosts) == 0 { t.Skip("Skipping memcached tests") } + p, err := NewMemcachedProvider(memcachedHosts[0:1]) require.NoError(t, err) @@ -64,6 +67,7 @@ func TestMemcachedPresentMultiHost(t *testing.T) { if len(memcachedHosts) <= 1 { t.Skip("Skipping memcached multi-host tests") } + p, err := NewMemcachedProvider(memcachedHosts) require.NoError(t, err) @@ -71,6 +75,7 @@ func TestMemcachedPresentMultiHost(t *testing.T) { err = p.Present(domain, token, keyAuth) require.NoError(t, err) + for _, host := range memcachedHosts { mc, err := memcache.New(host) require.NoError(t, err) @@ -84,6 +89,7 @@ func TestMemcachedPresentPartialFailureMultiHost(t *testing.T) { if len(memcachedHosts) == 0 { t.Skip("Skipping memcached tests") } + hosts := append(memcachedHosts, "5.5.5.5:11211") p, err := NewMemcachedProvider(hosts) require.NoError(t, err) @@ -92,6 +98,7 @@ func TestMemcachedPresentPartialFailureMultiHost(t *testing.T) { err = p.Present(domain, token, keyAuth) require.NoError(t, err) + for _, host := range memcachedHosts { mc, err := memcache.New(host) require.NoError(t, err) @@ -105,6 +112,7 @@ func TestMemcachedCleanup(t *testing.T) { if len(memcachedHosts) == 0 { t.Skip("Skipping memcached tests") } + p, err := NewMemcachedProvider(memcachedHosts) require.NoError(t, err) require.NoError(t, p.CleanUp(domain, token, keyAuth)) diff --git a/providers/http/s3/s3.go b/providers/http/s3/s3.go index 07e1eed63..e277deeea 100644 --- a/providers/http/s3/s3.go +++ b/providers/http/s3/s3.go @@ -57,6 +57,7 @@ func (s *HTTPProvider) Present(domain, token, keyAuth string) error { if err != nil { return fmt.Errorf("s3: failed to upload token to s3: %w", err) } + return nil } diff --git a/providers/http/webroot/webroot.go b/providers/http/webroot/webroot.go index c5b49caee..c94c4579c 100644 --- a/providers/http/webroot/webroot.go +++ b/providers/http/webroot/webroot.go @@ -29,6 +29,7 @@ func (w *HTTPProvider) Present(domain, token, keyAuth string) error { var err error challengeFilePath := filepath.Join(w.path, http01.ChallengePath(token)) + err = os.MkdirAll(filepath.Dir(challengeFilePath), 0o755) if err != nil { return fmt.Errorf("could not create required directories in webroot for HTTP challenge: %w", err) diff --git a/providers/http/webroot/webroot_test.go b/providers/http/webroot/webroot_test.go index 124b324a3..4c55e2b90 100644 --- a/providers/http/webroot/webroot_test.go +++ b/providers/http/webroot/webroot_test.go @@ -29,6 +29,7 @@ func TestHTTPProvider(t *testing.T) { } var data []byte + data, err = os.ReadFile(challengeFilePath) require.NoError(t, err) diff --git a/registration/registar.go b/registration/registar.go index 78e0ce7d8..5d3ea250b 100644 --- a/registration/registar.go +++ b/registration/registar.go @@ -15,7 +15,7 @@ const mailTo = "mailto:" // of which the client needs to keep track itself. // WARNING: will be removed in the future (acme.ExtendedAccount), https://github.com/go-acme/lego/issues/855. type Resource struct { - Body acme.Account `json:"body,omitempty"` + Body acme.Account `json:"body"` URI string `json:"uri,omitempty"` } @@ -60,7 +60,7 @@ func (r *Registrar) Register(options RegisterOptions) (*Resource, error) { account, err := r.core.Accounts.New(accMsg) if err != nil { // seems impossible - var errorDetails acme.ProblemDetails + errorDetails := &acme.ProblemDetails{} if !errors.As(err, &errorDetails) || errorDetails.HTTPStatus != http.StatusConflict { return nil, err } @@ -84,7 +84,7 @@ func (r *Registrar) RegisterWithExternalAccountBinding(options RegisterEABOption account, err := r.core.Accounts.NewEAB(accMsg, options.Kid, options.HmacEncoded) if err != nil { // seems impossible - var errorDetails acme.ProblemDetails + errorDetails := &acme.ProblemDetails{} if !errors.As(err, &errorDetails) || errorDetails.HTTPStatus != http.StatusConflict { return nil, err } @@ -160,6 +160,7 @@ func (r *Registrar) ResolveAccountByKey() (*Resource, error) { log.Infof("acme: Trying to resolve account by key") accMsg := acme.Account{OnlyReturnExisting: true} + account, err := r.core.Accounts.New(accMsg) if err != nil { return nil, err diff --git a/registration/registar_test.go b/registration/registar_test.go index efbc4f6f7..43df1d648 100644 --- a/registration/registar_test.go +++ b/registration/registar_test.go @@ -3,31 +3,30 @@ package registration import ( "crypto/rand" "crypto/rsa" + "fmt" "net/http" "testing" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRegistrar_ResolveAccountByKey(t *testing.T) { - mux, apiURL := tester.SetupFakeAPI(t) + server := tester.MockACMEServer(). + Route("/account", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Header().Set("Location", + fmt.Sprintf("http://%s/account", req.Context().Value(http.LocalAddrContextKey))) - mux.HandleFunc("/account", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", apiURL+"/account") - err := tester.WriteJSONResponse(w, acme.Account{ - Status: "valid", - }) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + servermock.JSONEncode(acme.Account{Status: "valid"}).ServeHTTP(rw, req) + })). + BuildHTTPS(t) - key, err := rsa.GenerateKey(rand.Reader, 512) + key, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") user := mockUser{ @@ -36,7 +35,7 @@ func TestRegistrar_ResolveAccountByKey(t *testing.T) { privatekey: key, } - core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key) + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) registrar := NewRegistrar(core, user)