Compare commits

..

12 commits

Author SHA1 Message Date
xenolf
aac83cdafb Merge branch 'cobraCLI' of https://github.com/gianluca311/lego into replace-cli-library 2016-04-14 20:25:04 +02:00
xenolf
28513346bc Add json logging for debugging of #190 2016-04-14 04:33:26 +02:00
Gianluca
ce773a2065 Adapted var names. old ones were missleading. 2016-04-11 10:21:34 +02:00
Gianluca
6114f8b6e7 removed typos in readme 2016-03-30 10:26:57 +02:00
Gianluca
e0a1dd6e9e adapted readme 2016-03-30 10:12:15 +02:00
Gianluca
58386e2d80 removed license headers from files. 2016-03-29 22:19:20 +02:00
Gianluca
ab2362bff4 changed imports to xenolf 2016-03-29 19:17:51 +02:00
Gianluca
0d6f04c434 added version command 2016-03-29 19:16:27 +02:00
Gianluca
9a58f91799 finished work on command renew 2016-03-29 19:11:11 +02:00
Gianluca
19ee614390 finished work on revoke 2016-03-29 19:04:32 +02:00
Gianluca
ce7dbe906d completed work on run command, moved util files in cmd/utils 2016-03-29 18:55:17 +02:00
Gianluca
ac406d0be7 inital for cobra structure
added commands from lego
2016-03-29 12:14:48 +02:00
1173 changed files with 7291 additions and 120210 deletions

View file

@ -1,8 +0,0 @@
lego.exe
.lego
.gitcookies
.idea
.vscode/
dist/
builds/
docs/

1
.github/FUNDING.yml vendored
View file

@ -1 +0,0 @@
github: ldez

View file

@ -1,104 +0,0 @@
name: Bug Report
description: Create a report to help us improve.
labels: [bug]
body:
- type: checkboxes
id: terms
attributes:
label: Welcome
options:
- label: Yes, I'm using a binary release within 2 latest releases.
required: true
- label: Yes, I've searched similar issues on GitHub and didn't find any.
required: true
- label: Yes, I've included all information below (version, config, etc).
required: true
- type: textarea
id: expected
attributes:
label: What did you expect to see?
placeholder: Description.
validations:
required: true
- type: textarea
id: current
attributes:
label: What did you see instead?
placeholder: Description.
validations:
required: true
- type: dropdown
id: type
attributes:
label: How do you use lego?
options:
- Library
- Binary
- Docker image
- Through Traefik
- Through Caddy
- Through Terraform ACME provider
- Through Bitnami
- Other
validations:
required: true
- type: textarea
id: steps
attributes:
label: Reproduction steps
description: "How do you trigger this bug? Please walk us through it step by step."
placeholder: |
1. ...
2. ...
3. ...
...
validations:
required: true
- type: textarea
id: version
attributes:
label: Version of lego
description: |-
```console
$ lego --version
```
placeholder: Paste output here
render: console
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs
value: |-
<details>
```console
# paste output here
```
</details>
validations:
required: true
- type: textarea
id: go-env
attributes:
label: Go environment (if applicable)
value: |-
<details>
```console
$ go version && go env
# paste output here
```
</details>
validations:
required: false

View file

@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Questions
url: https://github.com/go-acme/lego/discussions
about: If you have a question, or are looking for advice, please post on our Discussions section!
- name: lego documentation
url: https://go-acme.github.io/lego/
about: Please take a look to our documentation.

View file

@ -1,34 +0,0 @@
name: Feature request
description: Suggest an idea for this project.
body:
- type: checkboxes
id: terms
attributes:
label: Welcome
options:
- label: Yes, I've searched similar issues on GitHub and didn't find any.
required: true
- type: dropdown
id: type
attributes:
label: How do you use lego?
options:
- Library
- Binary
- Docker image
- Through Traefik
- Through Caddy
- Through Terraform ACME provider
- Through Bitnami
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: Detailed Description
placeholder: Description.
validations:
required: true

View file

@ -1,60 +0,0 @@
name: New DNS provider support
description: Request for the support of a new DNS provider.
title: "Support for provider: <put the name of your provider>"
labels: [enhancement, new-provider]
body:
- type: checkboxes
id: terms
attributes:
label: Welcome
options:
- label: Yes, I've searched 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
- 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.
required: false
- type: dropdown
id: type
attributes:
label: How do you use lego?
options:
- Library
- Binary
- Docker image
- Through Traefik
- Through Caddy
- Through Terraform ACME provider
- Through Bitnami
- Other
validations:
required: true
- type: input
id: provider-link
attributes:
label: Link to the DNS provider
placeholder: Put your link here.
validations:
required: true
- type: input
id: api-link
attributes:
label: Link to the API documentation
placeholder: Put your link here.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Additional Notes
placeholder: Your notes.
validations:
required: false

View file

@ -1,50 +0,0 @@
name: Documentation
on:
push:
branches:
- master
jobs:
doc:
name: Build and deploy documentation
runs-on: ubuntu-latest
env:
GO_VERSION: '1.20'
HUGO_VERSION: 0.101.0
CGO_ENABLED: 0
steps:
# https://github.com/marketplace/actions/setup-go-environment
- name: Set up Go ${{ env.GO_VERSION }}
uses: actions/setup-go@v3
with:
go-version: ${{ env.GO_VERSION }}
# https://github.com/marketplace/actions/checkout
- name: Check out code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Generate DNS docs
run: make generate-dns
- name: Install Hugo
run: |
wget -O /tmp/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_Linux-64bit.deb
sudo dpkg -i /tmp/hugo.deb
- name: Build Documentation
run: make docs-build
# https://github.com/marketplace/actions/github-pages
- name: Deploy to GitHub Pages
uses: crazy-max/ghaction-github-pages@v3
with:
target_branch: gh-pages
build_dir: docs/public
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,55 +0,0 @@
name: Go Matrix
on:
push:
branches:
- master
pull_request:
jobs:
cross:
name: Go
runs-on: ${{ matrix.os }}
env:
CGO_ENABLED: 0
strategy:
matrix:
go-version: [ '1.19', '1.20', 1.x ]
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
# https://github.com/marketplace/actions/setup-go-environment
- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go-version }}
# https://github.com/marketplace/actions/checkout
- name: Checkout code
uses: actions/checkout@v3
# https://github.com/marketplace/actions/cache
- name: Cache Go modules
uses: actions/cache@v3
with:
# In order:
# * Module download cache
# * Build cache (Linux)
# * Build cache (Mac)
# * Build cache (Windows)
path: |
~/go/pkg/mod
~/.cache/go-build
~/Library/Caches/go-build
%LocalAppData%\go-build
key: ${{ runner.os }}-${{ matrix.go-version }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-${{ matrix.go-version }}-go-
- name: Test
run: go test -v -cover ./...
- name: Build
run: go build -v -ldflags "-s -w" -trimpath -o ./dist/lego ./cmd/lego/

View file

@ -1,85 +0,0 @@
name: Main
on:
push:
branches:
- master
pull_request:
jobs:
main:
name: Main Process
runs-on: ubuntu-latest
env:
GO_VERSION: '1.20'
GOLANGCI_LINT_VERSION: v1.53.1
HUGO_VERSION: 0.54.0
CGO_ENABLED: 0
LEGO_E2E_TESTS: CI
MEMCACHED_HOSTS: localhost:11211
steps:
# https://github.com/marketplace/actions/setup-go-environment
- name: Set up Go ${{ env.GO_VERSION }}
uses: actions/setup-go@v3
with:
go-version: ${{ env.GO_VERSION }}
# https://github.com/marketplace/actions/checkout
- name: Check out code
uses: actions/checkout@v3
with:
fetch-depth: 0
# https://github.com/marketplace/actions/cache
- name: Cache Go modules
uses: actions/cache@v3
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Check and get dependencies
run: |
go mod tidy
git diff --exit-code go.mod
git diff --exit-code go.sum
# https://golangci-lint.run/usage/install#other-ci
- name: Install golangci-lint ${{ env.GOLANGCI_LINT_VERSION }}
run: |
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_LINT_VERSION}
golangci-lint --version
- name: Install Pebble
run: go install github.com/letsencrypt/pebble/v2/cmd/pebble@main
- name: Install challtestsrv
run: go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@main
- 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
- name: Make
run: |
make
make clean
- name: Install Hugo
run: |
wget -O /tmp/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_Linux-64bit.deb
sudo dpkg -i /tmp/hugo.deb
- name: Build Documentation
run: make docs-build

View file

@ -1,63 +0,0 @@
name: Release
on:
push:
tags:
- v*
jobs:
release:
name: Release version
runs-on: ubuntu-latest
env:
GO_VERSION: '1.20'
CGO_ENABLED: 0
steps:
# https://github.com/marketplace/actions/free-disk-space-ubuntu
- name: Free Disk Space
uses: jlumbroso/free-disk-space@main
with:
# this might remove tools that are actually needed
tool-cache: false
# all of these default to true
android: true
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: false
- name: Check out code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Go ${{ env.GO_VERSION }}
uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- name: Docker Login
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: echo "${DOCKER_PASSWORD}" | docker login --username "${DOCKER_USERNAME}" --password-stdin
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
# https://goreleaser.com/ci/actions/
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v4
with:
version: latest
args: release -p 1 --clean --timeout=90m
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN_REPO }}

7
.gitignore vendored
View file

@ -1,6 +1,3 @@
lego.exe
lego
.lego
.gitcookies
.idea
.vscode/
dist/
builds/

View file

@ -1,225 +0,0 @@
run:
timeout: 10m
skip-files: []
linters-settings:
govet:
check-shadowing: true
gocyclo:
min-complexity: 12
maligned:
suggest-new: true
goconst:
min-len: 3
min-occurrences: 3
funlen:
lines: -1
statements: 50
misspell:
locale: US
ignore-words:
- internetbs
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
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
linters:
enable-all: true
disable:
- deadcode # deprecated
- exhaustivestruct # deprecated
- golint # deprecated
- ifshort # deprecated
- interfacer # deprecated
- maligned # deprecated
- nosnakecase # deprecated
- scopelint # deprecated
- structcheck # deprecated
- varcheck # deprecated
- cyclop # duplicate of gocyclo
- sqlclosecheck # not relevant (SQL)
- rowserrcheck # not relevant (SQL)
- execinquery # not relevant (SQL)
- lll
- gosec
- dupl # not relevant
- prealloc # too many false-positive
- bodyclose # too many false-positive
- gomnd
- testpackage # not relevant
- tparallel # not relevant
- paralleltest # not relevant
- nestif # too many false-positive
- wrapcheck
- goerr113 # not relevant
- nlreturn # not relevant
- wsl # not relevant
- 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
- musttag # false-positive https://github.com/junk1tm/musttag/issues/17
- gosmopolitan # not relevant
issues:
exclude-use-default: false
max-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: providers/dns/dns_providers.go
linters:
- gocyclo
- path: providers/dns/gcloud/googlecloud_test.go
text: 'string `(lego\.wtf|manhattan)` has (\d+) occurrences, make it a constant'
- path: providers/dns/zoneee/zoneee_test.go
text: 'string `(bar|foo)` has (\d+) occurrences, make it a constant'
- path: certcrypto/crypto.go
text: '(tlsFeatureExtensionOID|ocspMustStapleFeature) is a global variable'
- path: challenge/dns01/nameserver.go
text: '(defaultNameservers|recursiveNameservers|fqdnSoaCache|muFqdnSoaCache) is a global variable'
- path: challenge/dns01/nameserver_.+.go
text: 'dnsTimeout is a global variable'
- path: challenge/dns01/nameserver_test.go
text: 'findXByFqdnTestCases is a global variable'
- path: challenge/http01/domain_matcher.go
text: 'string `Host` has \d occurrences, make it a constant'
- path: challenge/http01/domain_matcher.go
text: 'cyclomatic complexity \d+ of func `parseForwardedHeader` is high'
- path: challenge/http01/domain_matcher.go
text: "Function 'parseForwardedHeader' has too many statements"
- path: challenge/tlsalpn01/tls_alpn_challenge.go
text: 'idPeAcmeIdentifierV1 is a global variable'
- path: log/logger.go
text: 'Logger is a global variable'
- path: 'e2e/(dnschallenge/)?[\d\w]+_test.go'
text: load is a global variable
- path: 'providers/dns/([\d\w]+/)*[\d\w]+_test.go'
text: 'envTest is a global variable'
- path: providers/dns/namecheap/namecheap_test.go
text: 'testCases is a global variable'
- path: providers/dns/acmedns/acmedns_test.go
text: 'egTestAccount is a global variable'
- path: providers/http/memcached/memcached_test.go
text: 'memcachedHosts is a global variable'
- path: providers/dns/sakuracloud/client_test.go
text: 'cyclomatic complexity 13 of func `(TestDNSProvider_cleanupTXTRecord_concurrent|TestDNSProvider_addTXTRecord_concurrent)` is high'
- path: providers/dns/dns_providers.go
text: "Function 'NewDNSChallengeProviderByName' has too many statements"
- path: cmd/flags.go
text: "Function 'CreateFlags' is too long"
- path: certificate/certificates.go
text: "Function 'GetOCSP' is too long"
- path: providers/dns/otc/client.go
text: "Function 'loginRequest' is too long"
- path: providers/dns/gandi/gandi.go
text: "Function 'Present' is too long"
- path: cmd/zz_gen_cmd_dnshelp.go
linters:
- gocyclo
- funlen
- path: providers/dns/checkdomain/internal/types.go
text: '`payed` is a misspelling of `paid`'
- path: providers/dns/namecheap/namecheap_test.go
text: 'cognitive complexity (\d+) of func `TestDNSProvider_getHosts` is high'
- 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'
- path: challenge/http01/domain_matcher.go
text: 'yodaStyleExpr'
- path: providers/dns/dns_providers.go
text: 'Function name: NewDNSChallengeProviderByName,'
- path: providers/dns/sakuracloud/wrapper.go
text: 'mu is a global variable'
- path: providers/dns/hosttech/internal/client_test.go
text: 'Duplicate words \(0\) found'
- path: cmd/cmd_renew.go
text: 'cyclomatic complexity 16 of func `renewForDomains` is high'

View file

@ -1,135 +0,0 @@
project_name: lego
builds:
- binary: lego
main: ./cmd/lego/main.go
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X main.version={{.Version}}
goos:
- windows
- darwin
- linux
- freebsd
- openbsd
- solaris
goarch:
- amd64
- 386
- arm
- arm64
- mips
- mipsle
- mips64
- mips64le
goarm:
- 7
- 6
- 5
gomips:
- hardfloat
- softfloat
ignore:
- goos: darwin
goarch: 386
- goos: openbsd
goarch: arm
archives:
- id: lego
name_template: '{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}'
format: tar.gz
format_overrides:
- goos: windows
format: 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
dockerfile: buildx.Dockerfile
image_templates:
- 'goacme/lego:latest-amd64'
- 'goacme/lego:{{ .Tag }}-amd64'
- 'goacme/lego:v{{ .Major }}.{{ .Minor }}-amd64'
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/amd64'
- use: buildx
goos: linux
goarch: arm64
dockerfile: buildx.Dockerfile
image_templates:
- 'goacme/lego:latest-arm64'
- 'goacme/lego:{{ .Tag }}-arm64'
- 'goacme/lego:v{{ .Major }}.{{ .Minor }}-arm64'
build_flag_templates:
- '--pull'
# https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys
- '--label=org.opencontainers.image.title={{.ProjectName}}'
- '--label=org.opencontainers.image.description=Lets Encrypt/ACME client and library written in Go'
- '--label=org.opencontainers.image.source={{.GitURL}}'
- '--label=org.opencontainers.image.url={{.GitURL}}'
- '--label=org.opencontainers.image.documentation=https://go-acme.github.io/lego'
- '--label=org.opencontainers.image.created={{.Date}}'
- '--label=org.opencontainers.image.revision={{.FullCommit}}'
- '--label=org.opencontainers.image.version={{.Version}}'
- '--platform=linux/arm64'
- use: buildx
goos: linux
goarch: arm
goarm: '7'
dockerfile: buildx.Dockerfile
image_templates:
- 'goacme/lego:latest-armv7'
- 'goacme/lego:{{ .Tag }}-armv7'
- 'goacme/lego:v{{ .Major }}.{{ .Minor }}-armv7'
build_flag_templates:
- '--pull'
# https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys
- '--label=org.opencontainers.image.title={{.ProjectName}}'
- '--label=org.opencontainers.image.description=Lets Encrypt/ACME client and library written in Go'
- '--label=org.opencontainers.image.source={{.GitURL}}'
- '--label=org.opencontainers.image.url={{.GitURL}}'
- '--label=org.opencontainers.image.documentation=https://go-acme.github.io/lego'
- '--label=org.opencontainers.image.created={{.Date}}'
- '--label=org.opencontainers.image.revision={{.FullCommit}}'
- '--label=org.opencontainers.image.version={{.Version}}'
- '--platform=linux/arm/v7'

12
.travis.yml Normal file
View file

@ -0,0 +1,12 @@
language: go
go:
- 1.5.3
- 1.6
- tip
install:
- go get -t ./...
script:
- go vet ./...
- go test -v ./...
before_install:
- '[ "${TRAVIS_PULL_REQUEST}" = "false" ] && openssl aes-256-cbc -K $encrypted_26c593b079d9_key -iv $encrypted_26c593b079d9_iv -in .gitcookies.enc -out .gitcookies -d || true'

View file

@ -1,921 +1,5 @@
# Changelog
## [v4.13.2] - 2023-07-21
### Fixed:
- **[dnsprovider]** servercow: fix regression
## [v4.13.1] - 2023-07-20
### Added:
- **[dnsprovider]** Add DNS provider for IPv64
- **[dnsprovider]** Add DNS provider for Metaname
- **[dnsprovider]** Add DNS provider for RcodeZero
- **[dnsprovider]** Add DNS provider for Efficient IP
- **[dnsprovider]** azure: new implementation based on the new API client
- **[lib]** Experimental option to force DNS queries to use TCP
### Changed:
- **[dnsprovider]** cloudflare: update api client to v0.70.0
### Fixed:
- **[dnsprovider,cname]** fix: ensure case-insensitive comparison of CNAME records
- **[cli]** fix: list command
- **[lib]** fix: ARI explanationURL
## [v4.13.0] - 2023-07-20
Cancelled due to a CI issue (no space left on device).
## [v4.12.2] - 2023-06-19
### Fixed:
- **[dnsprovider]** dnsmadeeasy: fix DeleteRecord
- **[lib]** fix: read status code from response
## [v4.12.1] - 2023-06-06
### Fixed:
- **[dnsprovider]** pdns: fix record value
## [v4.12.0] - 2023-05-28
### Added:
- **[lib,cli]** Initial ACME Renewal Info (ARI) Implementation
- **[dnsprovider]** Add DNS provider for Derak Cloud
- **[dnsprovider]** route53: pass ExternalID property to STS:AssumeRole API operation
- **[lib,cli]** Support custom duration for certificate
### Changed:
- **[dnsprovider]** Refactor DNS provider and client implementations
### Fixed:
- **[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
### Added:
- **[lib]** Support for certificate with raw IP SAN (RFC8738)
- **[dnsprovider]** Add Brandit.com as DNS provider
- **[dnsprovider]** Add DNS provider for Bunny
- **[dnsprovider]** Add DNS provider for Nodion
- **[dnsprovider]** Add Google Domains as DNS provider
- **[dnsprovider]** Add DNS provider for Plesk
### Changed:
- **[cli]** feat: add LEGO_CERT_PEM_PATH and LEGO_CERT_PFX_PATH to run hook
- **[lib,cli]** feat: add RSA 3072
- **[dnsprovider]** gcloud: update google APIs to latest version
- **[lib,dnsprovider,cname]** chore: replace GetRecord by GetChallengeInfo
### Fixed:
- **[dnsprovider]** rimuhosting: fix API base URL
## [v4.10.2] - 2023-02-26
Fix Docker image builds.
## [v4.10.1] - 2023-02-25
### Fixed:
- **[dnsprovider,cname]** acmedns: fix CNAME support
- **[dnsprovider]** dynu: fix subdomain support
## [v4.10.0] - 2023-02-10
### Added:
- **[dnsprovider]** Add DNS provider for dnsHome.de
- **[dnsprovider]** Add DNS provider for Liara
- **[dnsprovider]** Add DNS provider for UltraDNS
- **[dnsprovider]** Add DNS provider for Websupport
### Changed:
- **[dnsprovider]** ibmcloud: add support for subdomains
- **[dnsprovider]** infomaniak: CNAME support
- **[dnsprovider]** namesilo: add cleanup before add a DNS record
- **[dnsprovider]** route53: Allow static credentials to be supplied
- **[dnsprovider]** tencentcloud: support punycode domain
### Fixed:
- **[dnsprovider]** alidns: filter on record type
- **[dnsprovider]** arvancloud: replace arvancloud.com by arvancloud.ir
- **[dnsprovider]** hetzner: improve zone ID detection
- **[dnsprovider]** luadns: removed dot suffix from authzone while searching for zone
- **[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
### 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
### Added:
- **[dnsprovider]** Add DNS provider for CIVO
- **[dnsprovider]** Add DNS provider for VK Cloud
- **[dnsprovider]** Add DNS provider for YandexCloud
- **[dnsprovider]** digitalocean: configurable base URL
- **[dnsprovider]** loopia: add configurable API endpoint
- **[dnsprovider]** pdns: notify secondary servers after updates
### Changed:
- **[dnsprovider]** allinkl: removed deprecated sha1 hashing
- **[dnsprovider]** auroradns: update authentification
- **[dnsprovider]** dnspod: deprecated. Use Tencent Cloud instead.
- **[dnsprovider]** exoscale: migrate to API v2 endpoints
- **[dnsprovider]** gcloud: update golang.org/x/oauth2
- **[dnsprovider]** lightsail: cleanup
- **[dnsprovider]** sakuracloud: update api client library
- **[cname]** take out CNAME support from experimental features
- **[lib,cname]** add recursive CNAME lookup support
- **[lib]** Remove embedded issuer certificates from issued certificate if bundle is false
### Fixed:
- **[dnsprovider]** luadns: fix cname support
- **[dnsprovider]** njalla: fix record id unmarshal error
- **[dnsprovider]** tencentcloud: fix subdomain error
## [v4.8.0] - 2022-06-30
### Added
- **[dnsprovider]** Add DNS provider for Variomedia
- **[dnsprovider]** Add NearlyFreeSpeech DNS Provider
- **[cli]** Add a --user-agent flag to lego-cli
### Changed
- new logo
- **[cli]** feat: sleep at renewal
- **[cli]** cli/renew: skip random sleep if stdout is a terminal
- **[dnsprovider]** hetzner: set min TTL to 60s
- **[docs]** refactoring and cleanup
## [v4.7.0] - 2022-05-27
### Added:
- **[dnsprovider]** Add DNS provider for iwantmyname
- **[dnsprovider]** Add DNS Provider for IIJ DNS Platform Service
- **[dnsprovider]** Add DNS provider for Vercel
- **[dnsprovider]** route53: add assume role ARN
- **[dnsprovider]** dnsimple: add debug option
- **[cli]** feat: add `LEGO_CERT_PEM_PATH` and `LEGO_CERT_PFX_PATH`
### Changed:
- **[dnsprovider]** gcore: change dns api url
- **[dnsprovider]** bluecat: rewrite provider implementation
### 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
### Added
- **[dnsprovider]** Add DNS provider for UKFast SafeDNS
- **[dnsprovider]** Add DNS Provider for Tencent Cloud
- **[dnsprovider]** azure: add support for Azure Private Zone DNS
- **[dnsprovider]** exec: add sequence interval
- **[cli]** Add a `--pfx`, and `--pfx.pas`s option to generate a PKCS#12 (`.pfx`) file.
- **[lib]** Extended support of cert pool (`LEGO_CA_CERTIFICATES` and `LEGO_CA_SYSTEM_CERT_POOL`)
- **[lib,httpprovider]** added uds capability to http challenge server
### Changed
- **[lib]** Extend validity of TLS-ALPN-01 certificates to 365 days
- **[lib,cli]** Allows defining the reason for the certificate revocation
### Fixed
- **[dnsprovider]** mythicbeasts: fix token expiration
- **[dnsprovider]** rackspace: change zone ID to string
## [v4.5.3] - 2021-09-06
### Fixed:
- **[lib,cli]** fix: missing preferred chain param for renew request
## [v4.5.2] - 2021-09-01
### Added:
- **[dnsprovider]** Add DNS provider for all-inkl
- **[dnsprovider]** Add DNS provider for Epik
- **[dnsprovider]** Add DNS provider for freemyip.com
- **[dnsprovider]** Add DNS provider for g-core labs
- **[dnsprovider]** Add DNS provider for hosttech
- **[dnsprovider]** Add DNS Provider for IBM Cloud (SoftLayer)
- **[dnsprovider]** Add DNS provider for Internet.bs
- **[dnsprovider]** Add DNS provider for nicmanager
### Changed:
- **[dnsprovider]** alidns: support ECS instance RAM role
- **[dnsprovider]** alidns: support sts token credential
- **[dnsprovider]** azure: zone name as environment variable
- **[dnsprovider]** ovh: follow cname
- **[lib,cli]** Add AlwaysDeactivateAuthorizations flag to ObtainRequest
### Fixed:
- **[dnsprovider]** infomaniak: fix subzone support
- **[dnsprovider]** edgedns: fix Present and CleanUp logic
- **[dnsprovider]** lightsail: wrong Region env var name
- **[lib]** lib: fix backoff in SolverManager
- **[lib]** lib: use permanent error instead of context cancellation
- **[dnsprovider]** desec: bump to v0.6.0
## [v4.5.1] - 2021-09-01
Cancelled due to a CI issue, replaced by v4.5.2.
## [v4.5.0] - 2021-09-30
Cancelled due to a CI issue, replaced by v4.5.2.
## [v4.4.0] - 2021-06-08
### Added:
- **[dnsprovider]** Add DNS provider for Infoblox
- **[dnsprovider]** Add DNS provider for Porkbun
- **[dnsprovider]** Add DNS provider for Simply.com
- **[dnsprovider]** Add DNS provider for Sonic
- **[dnsprovider]** Add DNS provider for VinylDNS
- **[dnsprovider]** Add DNS provider for wedos
### Changed:
- **[cli]** log: Use stderr instead of stdout.
- **[dnsprovider]** hostingde: autodetection of the zone name.
- **[dnsprovider]** scaleway: use official SDK
- **[dnsprovider]** powerdns: several improvements
- **[lib]** lib: improve wait.For returns.
### Fixed:
- **[dnsprovider]** hurricane: add API rate limiter.
- **[dnsprovider]** hurricane: only treat first word of response body as response code
- **[dnsprovider]** exoscale: fix DNS provider debugging
- **[dnsprovider]** wedos: fix api call parameters
- **[dnsprovider]** nifcloud: Get zone info from dns01.FindZoneByFqdn
- **[cli,lib]** csr: Support the type `NEW CERTIFICATE REQUEST`
## [v4.3.1] - 2021-03-12
### Fixed:
- **[dnsprovider]** exoscale: fix dependency version.
## [v4.3.0] - 2021-03-10
### Added:
- **[dnsprovider]** Add DNS provider for Njalla
- **[dnsprovider]** Add DNS provider for Domeneshop
- **[dnsprovider]** Add DNS provider for Hurricane Electric
- **[dnsprovider]** designate: support for Openstack Application Credentials
- **[dnsprovider]** edgedns: support for .edgerc file
### 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:
- **[dnsprovider]** desec: fix error with default TTL
- **[dnsprovider]** mythicbeasts: implement `ProviderTimeout`
- **[dnsprovider]** dnspod: improve search accuracy when a domain have more than 100 records
- **[lib]** Increase HTTP client timeouts
- **[lib]** preferred chain only match root name
## [v4.2.0] - 2021-01-24
### Added:
- **[dnsprovider]** Add DNS provider for Loopia
- **[dnsprovider]** Add DNS provider for Ionos.
### 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:
- **[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
### Fixed:
- **[dnsprovider]** azure: fix error handling.
## [v4.1.2] - 2020-11-21
### Fixed:
- **[lib]** fix: preferred chain support.
## [v4.1.1] - 2020-11-19
### 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
### 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:
- **[dnsprovider]** rfc2136: ensure TSIG algorithm is fully qualified
- **[dnsprovider]** designate: Deprecate OS_TENANT_NAME as required field
### 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
### Fixed:
- **[dnsprovider]** exoscale: change dependency version.
## [v4.0.0] - 2020-09-02
### Added:
- **[cli], [lib]** Support "alternate" certificate links for selecting different signing Chains
### Changed:
- **[cli]** Replaces `ec384` by `ec256` as default key-type
- **[lib]** Changes `ObtainForCSR` method signature
### Removed:
- **[dnsprovider]** Replaces FastDNS by EdgeDNS
- **[dnsprovider]** Removes old Linode provider
- **[lib]** Removes `AddPreCheck` function
## [v3.9.0] - 2020-09-01
### Added:
- **[dnsprovider]** Add Akamai Edgedns. Deprecate FastDNS
- **[dnsprovider]** Add DNS provider for HyperOne
### Changed:
- **[dnsprovider]** designate: add support for Openstack clouds.yaml
- **[dnsprovider]** azure: allow selecting environments
- **[dnsprovider]** desec: applies API rate limits.
### Fixed:
- **[dnsprovider]** namesilo: fix cleanup.
## [v3.8.0] - 2020-07-02
### Added:
- **[cli]** cli: add hook on the run command.
- **[dnsprovider]** inwx: Two-Factor-Authentication
- **[dnsprovider]** Add DNS provider for ArvanCloud
### 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:
- **[dnsprovider]** stackpath: fix subdomain support.
- **[dnsprovider]** arvandcloud: fix record name.
- **[dnsprovider]** fix: multi-va.
- **[dnsprovider]** constellix: fix search records API call.
- **[dnsprovider]** hetzner: fix record name.
- **[lib]** Registrar.ResolveAccountByKey: Fix malformed request
## [v3.7.0] - 2020-05-11
### Added:
- **[dnsprovider]** Add DNS provider for Netlify.
- **[dnsprovider]** Add DNS provider for deSEC.io
- **[dnsprovider]** Add DNS provider for LuaDNS
- **[dnsprovider]** Adding Hetzner DNS provider
- **[dnsprovider]** Add DNS provider for Mythic beasts DNSv2
- **[dnsprovider]** Add DNS provider for Yandex.
### Changed:
- **[dnsprovider]** Upgrade DNSimple client to 0.60.0
- **[dnsprovider]** update aws sdk
### 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
### 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:
- **[dnsprovider]** acmedns: Update cpu/goacmedns v0.0.1 -&gt; v0.0.2
- **[dnsprovider]** alicloud: update sdk dependency version to v1.61.112
- **[dnsprovider]** azure: Allow for the use of MSI
- **[dnsprovider]** constellix: improve challenge.
- **[dnsprovider]** godaddy: allow parallel solve.
- **[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:
- **[dnsprovider]** ns1: fix missing domain in log
- **[dnsprovider]** rimuhosting: use HTTP client from config.
## [v3.5.0] - 2020-03-15
### Added:
- **[dnsprovider]** Add DNS provider for Dynu.
- **[dnsprovider]** Add DNS provider for reg.ru
- **[dnsprovider]** Add DNS provider for Zonomi and RimuHosting.
- **[cli]** Building binaries for arm 6 and 5
- **[cli]** Uses CGO_ENABLED=0
- **[cli]** Multi-arch Docker image.
- **[cli]** Adds `--name` flag to list command.
### Changed:
- **[lib]** lib: Improve cleanup log messages.
- **[lib]** Wrap errors.
### 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
### Added:
- **[dnsprovider]** Add DNS provider for Constellix
- **[dnsprovider]** Add DNS provider for Servercow.
- **[dnsprovider]** Add DNS provider for Scaleway
- **[cli]** Add &#34;LEGO_PATH&#34; environment variable
### Changed:
- **[dnsprovider]** route53: allow custom client to be provided
- **[dnsprovider]** namecheap: allow external domains
- **[dnsprovider]** namecheap: add sandbox support.
- **[dnsprovider]** ovh: Improve provider documentation
- **[dnsprovider]** route53: Improve provider documentation
### Fixed:
- **[dnsprovider]** zoneee: fix subdomains.
- **[dnsprovider]** designate: Don&#39;t clean up managed records like SOA and NS
- **[dnsprovider]** dnspod: update lib.
- **[lib]** crypto: Treat CommonName as optional
- **[lib]** chore: update cenkalti/backoff to v4.
## [v3.3.0] - 2020-01-08
### Added:
- **[dnsprovider]** Add DNS provider for Checkdomain
- **[lib]** Add support to update account
### Changed:
- **[dnsprovider]** gcloud: Auto-detection of the project ID.
- **[lib]** Successfully parse private key PEM blocks
### Fixed:
- **[dnsprovider]** Update dnspod, because of API breaking changes.
## [v3.2.0] - 2019-11-10
### Added:
- **[dnsprovider]** Add support for autodns
### Changed:
- **[dnsprovider]** httpreq: Allow use environment vars from a `_FILE` file
- **[lib]** Don&#39;t deactivate valid authorizations
- **[lib]** Expose more SOA fields found by dns01.FindZoneByFqdn
### Fixed:
- **[dnsprovider]** use token as unique ID.
## [v3.1.0] - 2019-10-07
### Added:
- **[dnsprovider]** Add DNS provider for Liquid Web
- **[dnsprovider]** cloudflare: add support for API tokens
- **[cli]** feat: ease operation behind proxy servers
### Changed:
- **[dnsprovider]** cloudflare: update client
- **[dnsprovider]** linodev4: propagation timeout configuration.
### Fixed:
- **[dnsprovider]** ovh: fix int overflow.
- **[dnsprovider]** bindman: fix client version.
## [v3.0.2] - 2019-08-15
### Fixed:
- Invalid pseudo version (related to Cloudflare client).
## [v3.0.1] - 2019-08-14
There was a problem when creating the tag v3.0.1, this tag has been invalidate.
## [v3.0.0] - 2019-08-05
### Changed:
- migrate to go module (new import github.com/go-acme/lego/v3/)
- update DNS clients
## [v2.7.2] - 2019-07-30
### Fixed:
- **[dnsprovider]** vultr: quote TXT record
## [v2.7.1] - 2019-07-22
### Fixed:
- **[dnsprovider]** vultr: invalid record type.
## [v2.7.0] - 2019-07-17
### Added:
- **[dnsprovider]** Add DNS provider for namesilo
- **[dnsprovider]** Add DNS provider for versio.nl
### Changed:
- **[dnsprovider]** Update DNS providers libs.
- **[dnsprovider]** joker: support username and password.
- **[dnsprovider]** Vultr: Switch to official client
### Fixed:
- **[dnsprovider]** otc: Prevent sending empty body.
## [v2.6.0] - 2019-05-27
### 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:
- **[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:
- **[cli]** fix: cli disable-cp option.
- **[dnsprovider]** gcloud: fix zone visibility.
## [v2.5.0] - 2019-04-17
### Added:
- **[cli]** Adds renew hook
- **[dnsprovider]** Adds 'Since' to DNS providers documentation
### Changed:
- **[dnsprovider]** gcloud: use public DNS zones
- **[dnsprovider]** route53: enhance documentation.
### 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
- Migrate from xenolf/lego to go-acme/lego.
### Added:
- **[dnsprovider]** Add DNS Provider for Domain Offensive (do.de)
- **[dnsprovider]** Adds information about '_FILE' suffix.
### 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
### Added:
- **[dnsprovider]** Add DNS Provider for ClouDNS.net
- **[dnsprovider]** Add DNS Provider for Oracle Cloud
### 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:
- **[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
- **[dnsprovider]** vscale: getting sub-domain
- **[dnsprovider]** selectel: getting sub-domain
- **[dnsprovider]** vscale: fix TXT records clean up
- **[dnsprovider]** selectel: fix TXT records clean up
## [v2.2.0] - 2019-02-08
### 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:
- **[cli]** Applies Lets Encrypts recommendation about renew. The option `--days` of the command `renew` has a new default value (`30`)
- **[lib]** Uses a jittered exponential backoff
### 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
### Added:
- **[dnsprovider]** Add support for zone.ee as a DNS provider.
### Changed:
- **[dnsprovider]** nifcloud: Change DNS base url.
- **[dnsprovider]** gcloud: More detailed information about Google Cloud DNS.
### Fixed:
- **[lib]** fix: OCSP, set HTTP client.
- **[dnsprovider]** alicloud: fix pagination.
- **[dnsprovider]** namecheap: fix panic.
## [v2.0.0] - 2019-01-09
### 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
- **[cli]** Archive revoked certificates
- **[cli]** Add command to list certificates.
- **[cli]** support for renew with CSR
- **[cli]** add SAN on renew
- **[lib]** Adds `Remove` for challenges
- **[lib]** Add version to xenolf-acme in User-Agent.
- **[dnsprovider]** The ability for a DNS provider to solve the challenge sequentially
- **[dnsprovider]** Add DNS provider for &#34;HTTP request&#34;.
- **[dnsprovider]** Add DNS Provider for Vscale
- **[dnsprovider]** Add DNS Provider for TransIP
- **[dnsprovider]** Add DNS Provider for inwx
- **[dnsprovider]** alidns: add support to handle more than 20 domains
### 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
- **[lib]** Logs every iteration of waiting for the propagation
- **[cli]** `--http`: enable HTTP challenge **important**
- **[cli]** `--http.port`: previously named `--http`
- **[cli]** `--http.webroot`: previously named `--webroot`
- **[cli]** `--http.memcached-host`: previously named `--memcached-host`
- **[cli]** `--tls`: enable TLS challenge **important**
- **[cli]** `--tls.port`: previously named `--tls`
- **[cli]** `--dns.resolvers`: previously named `--dns-resolvers`
- **[cli]** the option `--days` of the command `renew` has default value (`15`)
- **[dnsprovider]** gcloud: Use GCE_PROJECT for project always, if specified
### Removed:
- **[lib]** Remove `SetHTTP01Address`
- **[lib]** Remove `SetTLSALPN01Address`
- **[lib]** Remove `Exclude`
- **[cli]** Remove `--exclude`, `-x`
### Fixed:
- **[lib]** Fixes revocation for subdomains and non-ascii domains
- **[lib]** Disable pending authorizations
- **[dnsprovider]** transip: concurrent access to the API.
- **[dnsprovider]** gcloud: fix for wildcard
- **[dnsprovider]** Azure: Do not overwrite existing TXT records
- **[dnsprovider]** fix: Cloudflare error.
## [v1.2.0] - 2018-11-04
### Added:
- **[dnsprovider]** Add DNS Provider for ConoHa DNS
- **[dnsprovider]** Add DNS Provider for MyDNS.jp
- **[dnsprovider]** Add DNS Provider for Selectel
### Fixed:
- **[dnsprovider]** netcup: make unmarshalling of api-responses more lenient.
### Changed:
- **[dnsprovider]** aurora: change DNS client
- **[dnsprovider]** azure: update auth to support instance metadata service
- **[dnsprovider]** dnsmadeeasy: log response body on error
- **[lib]** TLS-ALPN-01: Update idPeAcmeIdentifierV1, draft refs.
- **[lib]** Do not send a JWS body when POSTing challenges.
- **[lib]** Support POST-as-GET.
## [v1.1.0] - 2018-10-16
### Added:
- **[lib]** TLS-ALPN-01 Challenge
- **[cli]** Add filename parameter
- **[dnsprovider]** Allow to configure TTL, interval and timeout
- **[dnsprovider]** Add support for reading DNS provider setup from files
- **[dnsprovider]** Add DNS Provider for ACME-DNS
- **[dnsprovider]** Add DNS Provider for ALIYUN DNS
- **[dnsprovider]** Add DNS Provider for DreamHost
- **[dnsprovider]** Add DNS provider for hosting.de
- **[dnsprovider]** Add DNS Provider for IIJ
- **[dnsprovider]** Add DNS Provider for netcup
- **[dnsprovider]** Add DNS Provider for NIFCLOUD DNS
- **[dnsprovider]** Add DNS Provider for SAKURA Cloud
- **[dnsprovider]** Add DNS Provider for Stackpath
- **[dnsprovider]** Add DNS Provider for VegaDNS
- **[dnsprovider]** exec: add EXEC_MODE=RAW support.
- **[dnsprovider]** cloudflare: support for CF_API_KEY and CF_API_EMAIL
### Fixed:
- **[lib]** Don't trust identifiers order.
- **[lib]** Fix missing issuer certificates from Let's Encrypt
- **[dnsprovider]** duckdns: fix TXT record update url
- **[dnsprovider]** duckdns: fix subsubdomain
- **[dnsprovider]** gcloud: update findTxtRecords to use Name=fqdn and Type=TXT
- **[dnsprovider]** lightsail: Fix Domain does not exist error
- **[dnsprovider]** ns1: use the authoritative zone and not the domain name
- **[dnsprovider]** ovh: check error to avoid panic due to nil client
### Changed:
- **[lib]** Submit all dns records up front, then validate serially
## [v1.0.0] - 2018-05-30
### 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
### Added:
- **[dnsprovider]** Add DNS challenge provider `exec`
- **[dnsprovider]** Add DNS Provider for Akamai FastDNS
- **[dnsprovider]** Add DNS Provider for Bluecat DNS
- **[dnsprovider]** Add DNS Provider for CloudXNS
- **[dnsprovider]** Add DNS Provider for Duck DNS
- **[dnsprovider]** Add DNS Provider for Gandi Beta Platform (LiveDNS)
- **[dnsprovider]** Add DNS Provider for GleSYS API
- **[dnsprovider]** Add DNS Provider for GoDaddy
- **[dnsprovider]** Add DNS Provider for Lightsail
- **[dnsprovider]** Add DNS Provider for Name.com
### Fixed:
- **[dnsprovider]** Azure: Added missing environment variable in the comments
- **[dnsprovider]** PowerDNS: Fix zone URL, add leading slash.
- **[dnsprovider]** DNSimple: Fix api
- **[cli]** Correct help text for `--dns-resolvers` default.
- **[cli]** renew/revoke - don't panic on wrong account.
- **[lib]** Fix zone detection for cross-zone cnames.
- **[lib]** Use proxies from environment when making outbound http connections.
### 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.
- **[dnsprovider]** Dyn DNS: Slightly improve provider error reporting.
- **[dnsprovider]** Exoscale: update to latest egoscale version.
- **[dnsprovider]** Route53: Use NewSessionWithOptions instead of deprecated New.
## [0.4.1] - 2017-09-26
### 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` enviroment 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:
- lib: Fixed an authentication issue with the latest Azure SDK.
## [0.4.0] - 2017-07-13
### 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.
- lib: The `DNSTimeout` switch. This allows for an override of the default client DNS timeout.
- lib: The `QueryRegistration` function on `acme.Client`. This performs a POST on the client registration's URI and gets the updated registration info.
- lib: The `DeleteRegistration` function on `acme.Client`. This deletes the registration as currently configured in the client.
- lib: The `ObtainCertificateForCSR` function on `acme.Client`. The function allows to request a certificate for an already existing CSR.
- CLI: The `--csr` switch. Allows to use already existing CSRs for certificate requests on the command line.
- CLI: The `--pem` flag. This will change the certificate output so it outputs a .pem file concatanating the .key and .crt files together.
- CLI: The `--dns-resolvers` flag. Allows for users to override the default DNS servers used for recursive lookup.
- lib: Added a memcached provider for the HTTP challenge.
- CLI: The `--memcached-host` flag. This allows to use memcached for challenge storage.
- CLI: The `--must-staple` flag. This enables OCSP must staple in the generated CSR.
- lib: The library will now honor entries in your resolv.conf.
- lib: Added a field `IssuerCertificate` to the `CertificateResource` struct.
- lib: A new DNS provider for OVH.
- lib: A new DNS provider for DNSMadeEasy.
- lib: A new DNS provider for Linode.
- lib: A new DNS provider for AuroraDNS.
- lib: A new DNS provider for NS1.
- lib: A new DNS provider for Azure DNS.
- lib: A new DNS provider for Rackspace DNS.
- lib: A new DNS provider for Exoscale DNS.
- lib: A new DNS provider for DNSPod.
### 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:
- 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:
- 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 a integral part is nil
- lib: Fix a potential issue where the Dyn provider could resolve to an incorrect zone.
- lib: If a registration encounteres a conflict, the old registration is now recovered.
- CLI: The account.json file no longer has the executable flag set.
- lib: Made the client registration more robust in case of a 403 HTTP response.
- lib: Fixed an issue with zone lookups when they have a CNAME in another zone.
- lib: Fixed the lookup for the authoritative zone for Google Cloud.
- lib: Fixed a race condition in the nonce store.
- lib: The Google Cloud provider now removes old entries before trying to add new ones.
- 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
### Added:
- lib: A new DNS provider for Vultr.
### 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] - 2016-03-19
### Added:
@ -992,8 +76,7 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidate.
## [0.1.0] - 2015-12-03
- Initial release
[0.3.1]: https://github.com/go-acme/lego/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/go-acme/lego/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/go-acme/lego/compare/v0.1.1...v0.2.0
[0.1.1]: https://github.com/go-acme/lego/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/go-acme/lego/tree/v0.1.0
[0.3.0]: https://github.com/xenolf/lego/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/xenolf/lego/compare/v0.1.1...v0.2.0
[0.1.1]: https://github.com/xenolf/lego/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/xenolf/lego/tree/v0.1.0

View file

@ -30,51 +30,3 @@ a lot of time on something the project's developers might not want to merge into
**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+
- 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
# Add the go-acme/lego remote
git remote add upstream git@github.com:go-acme/lego.git
git fetch upstream
```
```bash
# Create your branch
git checkout -b my-feature
## Create your code ##
```
```bash
# Format
make fmt
# Linters
make checks
# Tests
make test
# Compile
make build
```
```bash
# push your branch
git push -u origin my-feature
## create a pull request on GitHub ##
```

View file

@ -1,24 +1,17 @@
FROM golang:1-alpine as builder
FROM alpine:3.3
RUN apk --no-cache --no-progress add make git
ENV GOPATH /go
WORKDIR /go/lego
RUN apk update && apk add ca-certificates go git && \
rm -rf /var/cache/apk/*
ENV GO111MODULE on
COPY . /go/src/github.com/xenolf/lego
# Download go modules
COPY go.mod .
COPY go.sum .
RUN go mod download
COPY . .
RUN make build
FROM alpine:3
RUN apk update \
&& apk add --no-cache ca-certificates tzdata \
&& update-ca-certificates
COPY --from=builder /go/lego/dist/lego /usr/bin/lego
RUN cd /go/src/github.com/xenolf/lego && \
go get ./... && \
go build -o /usr/bin/lego . && \
apk del ca-certificates go git && \
rm -rf /var/cache/apk/* && \
rm -rf /go
ENTRYPOINT [ "/usr/bin/lego" ]

View file

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2015-2017 Sebastian Erhart
Copyright (c) 2015 Sebastian Erhart
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View file

@ -1,80 +0,0 @@
.PHONY: clean checks test build image e2e fmt
export GO111MODULE=on
export CGO_ENABLED=0
LEGO_IMAGE := goacme/lego
MAIN_DIRECTORY := ./cmd/lego/
BIN_OUTPUT := $(if $(filter $(shell go env GOOS), windows), dist/lego.exe, dist/lego)
TAG_NAME := $(shell git tag -l --contains HEAD)
SHA := $(shell git rev-parse HEAD)
VERSION := $(if $(TAG_NAME),$(TAG_NAME),$(SHA))
default: clean generate-dns checks test build
clean:
@echo BIN_OUTPUT: ${BIN_OUTPUT}
rm -rf dist/ builds/ cover.out
build: clean
@echo Version: $(VERSION)
go build -trimpath -ldflags '-X "main.version=${VERSION}"' -o ${BIN_OUTPUT} ${MAIN_DIRECTORY}
image:
@echo Version: $(VERSION)
docker build -t $(LEGO_IMAGE) .
test: clean
go test -v -cover ./...
e2e: clean
LEGO_E2E_TESTS=local go test -count=1 -v ./e2e/...
checks:
golangci-lint run
# Release helper
.PHONY: patch minor major detach
patch:
go run internal/release.go release -m patch
minor:
go run internal/release.go release -m minor
major:
go run internal/release.go release -m major
detach:
go run internal/release.go detach
# Docs
.PHONY: docs-build docs-serve docs-themes
docs-build: generate-dns
@make -C ./docs hugo-build
docs-serve: generate-dns
@make -C ./docs hugo
docs-themes:
@make -C ./docs hugo-themes
# DNS Documentation
.PHONY: generate-dns validate-doc
generate-dns:
go generate ./...
validate-doc: generate-dns
validate-doc: DOC_DIRECTORIES := ./docs/ ./cmd/
validate-doc:
@if git diff --exit-code --quiet $(DOC_DIRECTORIES) 2>/dev/null; then \
echo 'All documentation changes are done the right way.'; \
else \
echo 'The documentation must be regenerated, please use `make generate-dns`.'; \
git status --porcelain -- $(DOC_DIRECTORIES) 2>/dev/null; \
exit 2; \
fi

299
README.md
View file

@ -1,90 +1,261 @@
<div align="center">
<img alt="lego logo" src="./docs/static/images/lego-logo.min.svg">
<p>Automatic Certificates and HTTPS for everyone.</p>
</div>
# lego
Let's Encrypt client and ACME library written in Go
# Lego
[![GoDoc](https://godoc.org/github.com/xenolf/lego/acme?status.svg)](https://godoc.org/github.com/xenolf/lego/acme)
[![Build Status](https://travis-ci.org/xenolf/lego.svg?branch=master)](https://travis-ci.org/xenolf/lego)
[![Dev Chat](https://img.shields.io/badge/dev%20chat-gitter-blue.svg?label=dev+chat)](https://gitter.im/xenolf/lego)
Let's Encrypt client and ACME library written in Go.
#### General
This is a work in progress. Please do *NOT* run this on a production server and please report any bugs you find!
[![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/)
#### Installation
lego supports both binary installs and install from source.
## Features
To get the binary just download the latest release for your OS/Arch from [the release page](https://github.com/xenolf/lego/releases)
and put the binary somewhere convenient. lego does not assume anything about the location you run it from.
To install from source, just run
```
go get -u github.com/xenolf/lego
```
To build lego inside a Docker container, just run
```
docker build -t lego .
```
#### 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 ApplicationLayer 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-01](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension
- Register with CA
- Obtain certificates, both from scratch or with an existing CSR
- Obtain certificates
- Renew certificates
- Revoke certificates
- Robust implementation of all ACME challenges
- HTTP (http-01)
- TLS with Server Name Indication (tls-sni-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/)
- Comes with multiple optional [DNS providers](https://github.com/xenolf/lego/tree/master/providers/dns)
- [Custom challenge solvers](https://github.com/xenolf/lego/wiki/Writing-a-Challenge-Solver)
- Certificate bundling
- OCSP helper function
## Installation
Please keep in mind that CLI switches and APIs are still subject to change.
How to [install](https://go-acme.github.io/lego/installation/).
When using the standard `--path` option, all certificates and account configurations are saved to a folder *.lego* in the current working directory.
## Usage
#### Sudo
The CLI does not require root permissions but needs to bind to port 80 and 443 for certain challenges.
To run the CLI without sudo, you have four options:
- as a [CLI](https://go-acme.github.io/lego/usage/cli)
- as a [library](https://go-acme.github.io/lego/usage/library)
- Use setcap 'cap_net_bind_service=+ep' /path/to/program
- Pass the `--http` or/and the `--tls` option and specify a custom port to bind to. In this case you have to forward port 80/443 to these custom ports (see [Port Usage](#port-usage)).
- Pass the `--webroot` option and specify the path to your webroot folder. In this case the challenge will be written in a file in `.well-known/acme-challenge/` inside your webroot.
- Pass the `--dns` option and specify a DNS provider.
## Documentation
#### Port Usage
By default lego assumes it is able to bind to ports 80 and 443 to solve challenges.
If this is not possible in your environment, you can use the `--http` and `--tls` options to instruct
lego to listen on that interface:port for any incoming challenges.
Documentation is hosted live at https://go-acme.github.io/lego/.
If you are using this option, make sure you proxy all of the following traffic to these ports.
## DNS providers
HTTP Port:
- All plaintext HTTP requests to port 80 which begin with a request path of `/.well-known/acme-challenge/` for the HTTP challenge.
Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
TLS Port:
- All TLS handshakes on port 443 for the TLS-SNI challenge.
<!-- START DNS PROVIDERS LIST -->
This traffic redirection is only needed as long as lego solves challenges. As soon as you have received your certificates you can deactivate the forwarding.
| | | | |
|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
| [Akamai EdgeDNS](https://go-acme.github.io/lego/dns/edgedns/) | [Alibaba Cloud DNS](https://go-acme.github.io/lego/dns/alidns/) | [all-inkl](https://go-acme.github.io/lego/dns/allinkl/) | [Amazon Lightsail](https://go-acme.github.io/lego/dns/lightsail/) |
| [Amazon Route 53](https://go-acme.github.io/lego/dns/route53/) | [ArvanCloud](https://go-acme.github.io/lego/dns/arvancloud/) | [Aurora DNS](https://go-acme.github.io/lego/dns/auroradns/) | [Autodns](https://go-acme.github.io/lego/dns/autodns/) |
| [Azure (deprecated)](https://go-acme.github.io/lego/dns/azure/) | [AzureDNS](https://go-acme.github.io/lego/dns/azuredns/) | [Bindman](https://go-acme.github.io/lego/dns/bindman/) | [Bluecat](https://go-acme.github.io/lego/dns/bluecat/) |
| [Brandit](https://go-acme.github.io/lego/dns/brandit/) | [Bunny](https://go-acme.github.io/lego/dns/bunny/) | [Checkdomain](https://go-acme.github.io/lego/dns/checkdomain/) | [Civo](https://go-acme.github.io/lego/dns/civo/) |
| [CloudDNS](https://go-acme.github.io/lego/dns/clouddns/) | [Cloudflare](https://go-acme.github.io/lego/dns/cloudflare/) | [ClouDNS](https://go-acme.github.io/lego/dns/cloudns/) | [CloudXNS](https://go-acme.github.io/lego/dns/cloudxns/) |
| [ConoHa](https://go-acme.github.io/lego/dns/conoha/) | [Constellix](https://go-acme.github.io/lego/dns/constellix/) | [Derak Cloud](https://go-acme.github.io/lego/dns/derak/) | [deSEC.io](https://go-acme.github.io/lego/dns/desec/) |
| [Designate DNSaaS for Openstack](https://go-acme.github.io/lego/dns/designate/) | [Digital Ocean](https://go-acme.github.io/lego/dns/digitalocean/) | [DNS Made Easy](https://go-acme.github.io/lego/dns/dnsmadeeasy/) | [dnsHome.de](https://go-acme.github.io/lego/dns/dnshomede/) |
| [DNSimple](https://go-acme.github.io/lego/dns/dnsimple/) | [DNSPod (deprecated)](https://go-acme.github.io/lego/dns/dnspod/) | [Domain Offensive (do.de)](https://go-acme.github.io/lego/dns/dode/) | [Domeneshop](https://go-acme.github.io/lego/dns/domeneshop/) |
| [DreamHost](https://go-acme.github.io/lego/dns/dreamhost/) | [Duck DNS](https://go-acme.github.io/lego/dns/duckdns/) | [Dyn](https://go-acme.github.io/lego/dns/dyn/) | [Dynu](https://go-acme.github.io/lego/dns/dynu/) |
| [EasyDNS](https://go-acme.github.io/lego/dns/easydns/) | [Efficient IP](https://go-acme.github.io/lego/dns/efficientip/) | [Epik](https://go-acme.github.io/lego/dns/epik/) | [Exoscale](https://go-acme.github.io/lego/dns/exoscale/) |
| [External program](https://go-acme.github.io/lego/dns/exec/) | [freemyip.com](https://go-acme.github.io/lego/dns/freemyip/) | [G-Core](https://go-acme.github.io/lego/dns/gcore/) | [Gandi Live DNS (v5)](https://go-acme.github.io/lego/dns/gandiv5/) |
| [Gandi](https://go-acme.github.io/lego/dns/gandi/) | [Glesys](https://go-acme.github.io/lego/dns/glesys/) | [Go Daddy](https://go-acme.github.io/lego/dns/godaddy/) | [Google Cloud](https://go-acme.github.io/lego/dns/gcloud/) |
| [Google Domains](https://go-acme.github.io/lego/dns/googledomains/) | [Hetzner](https://go-acme.github.io/lego/dns/hetzner/) | [Hosting.de](https://go-acme.github.io/lego/dns/hostingde/) | [Hosttech](https://go-acme.github.io/lego/dns/hosttech/) |
| [HTTP request](https://go-acme.github.io/lego/dns/httpreq/) | [Hurricane Electric DNS](https://go-acme.github.io/lego/dns/hurricane/) | [HyperOne](https://go-acme.github.io/lego/dns/hyperone/) | [IBM Cloud (SoftLayer)](https://go-acme.github.io/lego/dns/ibmcloud/) |
| [IIJ DNS Platform Service](https://go-acme.github.io/lego/dns/iijdpf/) | [Infoblox](https://go-acme.github.io/lego/dns/infoblox/) | [Infomaniak](https://go-acme.github.io/lego/dns/infomaniak/) | [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) |
| [Internet.bs](https://go-acme.github.io/lego/dns/internetbs/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) | [Ionos](https://go-acme.github.io/lego/dns/ionos/) | [IPv64](https://go-acme.github.io/lego/dns/ipv64/) |
| [iwantmyname](https://go-acme.github.io/lego/dns/iwantmyname/) | [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | [Liara](https://go-acme.github.io/lego/dns/liara/) |
| [Linode (v4)](https://go-acme.github.io/lego/dns/linode/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | [Loopia](https://go-acme.github.io/lego/dns/loopia/) | [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) |
| [Manual](https://go-acme.github.io/lego/dns/manual/) | [Metaname](https://go-acme.github.io/lego/dns/metaname/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [MythicBeasts](https://go-acme.github.io/lego/dns/mythicbeasts/) |
| [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) | [NearlyFreeSpeech.NET](https://go-acme.github.io/lego/dns/nearlyfreespeech/) |
| [Netcup](https://go-acme.github.io/lego/dns/netcup/) | [Netlify](https://go-acme.github.io/lego/dns/netlify/) | [Nicmanager](https://go-acme.github.io/lego/dns/nicmanager/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) |
| [Njalla](https://go-acme.github.io/lego/dns/njalla/) | [Nodion](https://go-acme.github.io/lego/dns/nodion/) | [NS1](https://go-acme.github.io/lego/dns/ns1/) | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) |
| [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | [OVH](https://go-acme.github.io/lego/dns/ovh/) | [plesk.com](https://go-acme.github.io/lego/dns/plesk/) | [Porkbun](https://go-acme.github.io/lego/dns/porkbun/) |
| [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [RcodeZero](https://go-acme.github.io/lego/dns/rcodezero/) | [reg.ru](https://go-acme.github.io/lego/dns/regru/) |
| [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) |
| [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Simply.com](https://go-acme.github.io/lego/dns/simply/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) |
| [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [Tencent Cloud DNS](https://go-acme.github.io/lego/dns/tencentcloud/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [UKFast SafeDNS](https://go-acme.github.io/lego/dns/safedns/) |
| [Ultradns](https://go-acme.github.io/lego/dns/ultradns/) | [Variomedia](https://go-acme.github.io/lego/dns/variomedia/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Vercel](https://go-acme.github.io/lego/dns/vercel/) |
| [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | [VK Cloud](https://go-acme.github.io/lego/dns/vkcloud/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) |
| [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [Websupport](https://go-acme.github.io/lego/dns/websupport/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | [Yandex Cloud](https://go-acme.github.io/lego/dns/yandexcloud/) |
| [Yandex PDD](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | |
#### Usage
<!-- END DNS PROVIDERS LIST -->
```
Let's Encrypt client written in Go
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).
Usage:
lego [command]
Available Commands:
dnshelp Shows additional help for the --dns global option
renew Renew a certificate
revoke Revoke a certificate
run Register an account, then create and install a certificate
version Prints current version of lego
Flags:
-a, --accept-tos By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service.
--dns string Solve a DNS challenge using the specified provider. Disables all other challenges. Run 'lego dnshelp' for help on usage.
-d, --domains value Add domains to the process (default [])
-m, --email string Email used for registration and recovery contact.
-x, --exclude value Explicitly disallow solvers by name from being used. Solvers: "http-01", "tls-sni-01". (default [])
-h, --help help for lego
--http string Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port
-k, --key-type string Key type to use for private keys. Supported: rsa2048, rsa4096, rsa8192, ec256, ec384 (default "rsa2048")
--path string Directory to use for storing the data (default "{$CWD}/.lego")
-s, --server string CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. (default "https://acme-v01.api.letsencrypt.org/directory")
--tls string Set the port and interface to use for TLS based challenges to listen on. Supported: interface:port or :port
--webroot string Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge
Use "lego [command] --help" for more information about a command.
```
For further help on a command:
```
$ lego renew --help
Renew a certificate
Usage:
lego renew [flags]
Flags:
--days int The number of days left on a certificate to renew it.
--no-bundle Do not create a certificate bundle by adding the issuers certificate to the new certificate.
--resuse-key Used to indicate you want to reuse your current private key for the new certificate.
...
```
##### CLI Example
Assumes the `lego` binary has permission to bind to ports 80 and 443. You can get a pre-built binary from the [releases](https://github.com/xenolf/lego/releases) page.
If your environment does not allow you to bind to these ports, please read [Port Usage](#port-usage).
Obtain a certificate:
```bash
$ lego run --email="foo@bar.com" --domains="example.com"
```
(Find your certificate in the `.lego` folder of current working directory.)
To renew the certificate:
```bash
$ lego renew --email="foo@bar.com" --domains="example.com"
```
Obtain a certificate using the DNS challenge and AWS Route 53:
```bash
$ AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=my_id AWS_SECRET_ACCESS_KEY=my_key lego run --email="foo@bar.com" --domains="example.com" --dns="route53"
```
Note that `--dns=foo` implies `--exclude=http-01` and `--exclude=tls-sni-01`. lego will not attempt other challenges if you've told it to use DNS instead.
lego defaults to communicating with the production Let's Encrypt ACME server. If you'd like to test something without issuing real certificates, consider using the staging endpoint instead:
```bash
$ lego --server=https://acme-staging.api.letsencrypt.org/directory …
```
#### DNS Challenge API Details
##### AWS Route 53
The following AWS IAM policy document describes the permissions required for lego to complete the DNS challenge.
Replace `<INSERT_YOUR_HOSTED_ZONE_ID_HERE>` with the Route 53 zone ID of the domain you are authorizing.
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"route53:GetChange",
"route53:ListHostedZonesByName"
],
"Resource": [
"*"
]
},
{
"Effect": "Allow",
"Action": [
"route53:ChangeResourceRecordSets"
],
"Resource": [
"arn:aws:route53:::hostedzone/<INSERT_YOUR_HOSTED_ZONE_ID_HERE>"
]
}
]
}
```
#### ACME Library Usage
A valid, but bare-bones example use of the acme package:
```go
// You'll need a user or account type that implements acme.User
type MyUser struct {
Email string
Registration *acme.RegistrationResource
key crypto.PrivateKey
}
func (u MyUser) GetEmail() string {
return u.Email
}
func (u MyUser) GetRegistration() *acme.RegistrationResource {
return u.Registration
}
func (u MyUser) GetPrivateKey() crypto.PrivateKey {
return u.key
}
// Create a user. New accounts need an email and private key to start.
const rsaKeySize = 2048
privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySize)
if err != nil {
log.Fatal(err)
}
myUser := MyUser{
Email: "you@yours.com",
key: privateKey,
}
// A client facilitates communication with the CA server. This CA URL is
// configured for a local dev instance of Boulder running in Docker in a VM.
client, err := acme.NewClient("http://192.168.99.100:4000", &myUser, acme.RSA2048)
if err != nil {
log.Fatal(err)
}
// We specify an http port of 5002 and an tls port of 5001 on all interfaces
// because we aren't running as root and can't bind a listener to port 80 and 443
// (used later when we attempt to pass challenges). Keep in mind that we still
// need to proxy challenge traffic to port 5002 and 5001.
client.SetHTTPAddress(":5002")
client.SetTLSAddress(":5001")
// New users will need to register
reg, err := client.Register()
if err != nil {
log.Fatal(err)
}
myUser.Registration = reg
// SAVE THE USER.
// The client has a URL to the current Let's Encrypt Subscriber
// Agreement. The user will need to agree to it.
err = client.AgreeToTOS()
if err != nil {
log.Fatal(err)
}
// The acme library takes care of completing the challenges to obtain the certificate(s).
// The domains must resolve to this machine or you have to use the DNS challenge.
bundle := false
certificates, failures := client.ObtainCertificate([]string{"mydomain.com"}, bundle, nil)
if len(failures) > 0 {
log.Fatal(failures)
}
// Each certificate comes back with the cert bytes, the bytes of the client's
// private key, and a certificate URL. SAVE THESE TO DISK.
fmt.Printf("%#v\n", certificates)
// ... all done.
```

View file

@ -1,85 +0,0 @@
package api
import (
"encoding/base64"
"errors"
"fmt"
"github.com/go-acme/lego/v4/acme"
)
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)
if len(location) > 0 {
a.core.jws.SetKid(location)
}
if err != nil {
return acme.ExtendedAccount{Location: location}, err
}
return acme.ExtendedAccount{Account: account, Location: location}, nil
}
// 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)
if err != nil {
return acme.ExtendedAccount{}, fmt.Errorf("acme: could not decode hmac key: %w", err)
}
eabJWS, err := a.core.signEABContent(a.core.GetDirectory().NewAccountURL, kid, hmac)
if err != nil {
return acme.ExtendedAccount{}, fmt.Errorf("acme: error signing eab content: %w", err)
}
accMsg.ExternalAccountBinding = eabJWS
return a.New(accMsg)
}
// Get Retrieves an account.
func (a *AccountService) Get(accountURL string) (acme.Account, error) {
if accountURL == "" {
return acme.Account{}, errors.New("account[get]: empty URL")
}
var account acme.Account
_, err := a.core.postAsGet(accountURL, &account)
if err != nil {
return acme.Account{}, err
}
return account, nil
}
// Update Updates an account.
func (a *AccountService) Update(accountURL string, req acme.Account) (acme.Account, error) {
if accountURL == "" {
return acme.Account{}, errors.New("account[update]: empty URL")
}
var account acme.Account
_, err := a.core.post(accountURL, req, &account)
if err != nil {
return acme.Account{}, err
}
return account, nil
}
// Deactivate Deactivates an account.
func (a *AccountService) Deactivate(accountURL string) error {
if accountURL == "" {
return errors.New("account[deactivate]: empty URL")
}
req := acme.Account{Status: acme.StatusDeactivated}
_, err := a.core.post(accountURL, req, nil)
return err
}

View file

@ -1,165 +0,0 @@
package api
import (
"bytes"
"crypto"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/cenkalti/backoff/v4"
"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"
"github.com/go-acme/lego/v4/acme/api/internal/sender"
"github.com/go-acme/lego/v4/log"
)
// Core ACME/LE core API.
type Core struct {
doer *sender.Doer
nonceManager *nonces.Manager
jws *secure.JWS
directory acme.Directory
HTTPClient *http.Client
common service // Reuse a single struct instead of allocating one for each service on the heap.
Accounts *AccountService
Authorizations *AuthorizationService
Certificates *CertificateService
Challenges *ChallengeService
Orders *OrderService
}
// New Creates a new Core.
func New(httpClient *http.Client, userAgent, caDirURL, kid string, privateKey crypto.PrivateKey) (*Core, error) {
doer := sender.NewDoer(httpClient, userAgent)
dir, err := getDirectory(doer, caDirURL)
if err != nil {
return nil, err
}
nonceManager := nonces.NewManager(doer, dir.NewNonceURL)
jws := secure.NewJWS(privateKey, kid, nonceManager)
c := &Core{doer: doer, nonceManager: nonceManager, jws: jws, directory: dir, HTTPClient: httpClient}
c.common.core = c
c.Accounts = (*AccountService)(&c.common)
c.Authorizations = (*AuthorizationService)(&c.common)
c.Certificates = (*CertificateService)(&c.common)
c.Challenges = (*ChallengeService)(&c.common)
c.Orders = (*OrderService)(&c.common)
return c, nil
}
// 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) {
content, err := json.Marshal(reqBody)
if err != nil {
return nil, errors.New("failed to marshal message")
}
return a.retrievablePost(uri, content, 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) {
return a.retrievablePost(uri, []byte{}, response)
}
func (a *Core) retrievablePost(uri string, content []byte, response interface{}) (*http.Response, error) {
// 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)
if err != nil {
// Retry if the nonce was invalidated
var e *acme.NonceError
if errors.As(err, &e) {
return err
}
return backoff.Permanent(err)
}
return 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
}
func (a *Core) signedPost(uri string, content []byte, response interface{}) (*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)
}
signedBody := bytes.NewBufferString(signedContent.FullSerialize())
resp, err := a.doer.Post(uri, signedBody, "application/jose+json", response)
// nonceErr is ignored to keep the root error.
nonce, nonceErr := nonces.GetFromResponse(resp)
if nonceErr == nil {
a.nonceManager.Push(nonce)
}
return resp, err
}
func (a *Core) signEABContent(newAccountURL, kid string, hmac []byte) ([]byte, error) {
eabJWS, err := a.jws.SignEABContent(newAccountURL, kid, hmac)
if err != nil {
return nil, err
}
return []byte(eabJWS.FullSerialize()), nil
}
// GetKeyAuthorization Gets the key authorization.
func (a *Core) GetKeyAuthorization(token string) (string, error) {
return a.jws.GetKeyAuthorization(token)
}
func (a *Core) GetDirectory() acme.Directory {
return a.directory
}
func getDirectory(do *sender.Doer, caDirURL string) (acme.Directory, error) {
var dir acme.Directory
if _, err := do.Get(caDirURL, &dir); err != nil {
return dir, fmt.Errorf("get directory at '%s': %w", caDirURL, err)
}
if dir.NewAccountURL == "" {
return dir, errors.New("directory missing new registration URL")
}
if dir.NewOrderURL == "" {
return dir, errors.New("directory missing new order URL")
}
return dir, nil
}

View file

@ -1,34 +0,0 @@
package api
import (
"errors"
"github.com/go-acme/lego/v4/acme"
)
type AuthorizationService service
// Get Gets an authorization.
func (c *AuthorizationService) Get(authzURL string) (acme.Authorization, error) {
if authzURL == "" {
return acme.Authorization{}, errors.New("authorization[get]: empty URL")
}
var authz acme.Authorization
_, err := c.core.postAsGet(authzURL, &authz)
if err != nil {
return acme.Authorization{}, err
}
return authz, nil
}
// Deactivate Deactivates an authorization.
func (c *AuthorizationService) Deactivate(authzURL string) error {
if authzURL == "" {
return errors.New("authorization[deactivate]: empty URL")
}
var disabledAuth acme.Authorization
_, err := c.core.post(authzURL, acme.Authorization{Status: acme.StatusDeactivated}, &disabledAuth)
return err
}

View file

@ -1,138 +0,0 @@
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.
const maxBodySize = 1024 * 1024
type CertificateService service
// Get Returns the certificate and the issuer certificate.
// 'bundle' is only applied if the issuer is provided by the 'up' link.
func (c *CertificateService) Get(certURL string, bundle bool) ([]byte, []byte, error) {
cert, _, err := c.get(certURL, bundle)
if err != nil {
return nil, nil, err
}
return cert.Cert, cert.Issuer, nil
}
// GetAll the certificates and the alternate certificates.
// bundle' is only applied if the issuer is provided by the 'up' link.
func (c *CertificateService) GetAll(certURL string, bundle bool) (map[string]*acme.RawCertificate, error) {
cert, headers, err := c.get(certURL, bundle)
if err != nil {
return nil, err
}
certs := map[string]*acme.RawCertificate{certURL: cert}
// URLs of "alternate" link relation
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4.2
alts := getLinks(headers, "alternate")
for _, alt := range alts {
altCert, _, err := c.get(alt, bundle)
if err != nil {
return nil, err
}
certs[alt] = altCert
}
return certs, nil
}
// Revoke Revokes a certificate.
func (c *CertificateService) Revoke(req acme.RevokeCertMessage) error {
_, err := c.core.post(c.core.GetDirectory().RevokeCertURL, req, nil)
return err
}
// get Returns the certificate and the "up" link.
func (c *CertificateService) get(certURL string, bundle bool) (*acme.RawCertificate, http.Header, error) {
if certURL == "" {
return nil, nil, errors.New("certificate[get]: empty URL")
}
resp, err := c.core.postAsGet(certURL, nil)
if err != nil {
return nil, nil, err
}
data, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxBodySize))
if err != nil {
return nil, resp.Header, err
}
cert := c.getCertificateChain(data, resp.Header, bundle, certURL)
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 {
// 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...)
}
}
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
}

View file

@ -1,130 +0,0 @@
package api
import (
"crypto/rand"
"crypto/rsa"
"encoding/pem"
"net/http"
"testing"
"github.com/go-acme/lego/v4/platform/tester"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const certResponseMock = `-----BEGIN CERTIFICATE-----
MIIDEDCCAfigAwIBAgIHPhckqW5fPDANBgkqhkiG9w0BAQsFADAoMSYwJAYDVQQD
Ex1QZWJibGUgSW50ZXJtZWRpYXRlIENBIDM5NWU2MTAeFw0xODExMDcxNzQ2NTZa
Fw0yMzExMDcxNzQ2NTZaMBMxETAPBgNVBAMTCGFjbWUud3RmMIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwtLNKvZXD20XPUQCWYSK9rUSKxD9Eb0c9fag
bxOxOkLRTgL8LH6yln+bxc3MrHDou4PpDUdeo2CyOQu3CKsTS5mrH3NXYHu0H7p5
y3riOJTHnfkGKLT9LciGz7GkXd62nvNP57bOf5Sk4P2M+Qbxd0hPTSfu52740LSy
144cnxe2P1aDYehrEp6nYCESuyD/CtUHTo0qwJmzIy163Sp3rSs15BuCPyhySnE3
BJ8Ggv+qC6D5I1932DfSqyQJ79iq/HRm0Fn84am3KwvRlUfWxabmsUGARXoqCgnE
zcbJVOZKewv0zlQJpfac+b+Imj6Lvt1TGjIz2mVyefYgLx8gwwIDAQABo1QwUjAO
BgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwG
A1UdEwEB/wQCMAAwEwYDVR0RBAwwCoIIYWNtZS53dGYwDQYJKoZIhvcNAQELBQAD
ggEBABB/0iYhmfPSQot5RaeeovQnsqYjI5ryQK2cwzW6qcTJfv8N6+p6XkqF1+W4
jXZjrQP8MvgO9KNWlvx12vhINE6wubk88L+2piAi5uS2QejmZbXpyYB9s+oPqlk9
IDvfdlVYOqvYAhSx7ggGi+j73mjZVtjAavP6dKuu475ZCeq+NIC15RpbbikWKtYE
HBJ7BW8XQKx67iHGx8ygHTDLbREL80Bck3oUm7wIYGMoNijD6RBl25p4gYl9dzOd
TqGl5hW/1P5hMbgEzHbr4O3BfWqU2g7tV36TASy3jbC3ONFRNNYrpEZ1AL3+cUri
OPPkKtAKAbQkKbUIfsHpBZjKZMU=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDDDCCAfSgAwIBAgIIOV5hkYJx0JwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
AxMVUGViYmxlIFJvb3QgQ0EgNTBmZmJkMB4XDTE4MTEwNzE3NDY0N1oXDTQ4MTEw
NzE3NDY0N1owKDEmMCQGA1UEAxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSAzOTVl
NjEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCacwXN4LmyRTgYS8TT
SZYgz758npHiPTBDKgeN5WVmkkwW0TuN4W2zXhEmcM82uxOEjWS2drvK0+iJKneh
0fQR8ZF35dIYFe8WXTg3kEmqcizSgh4LxlOntsXvatfX/6GU/ADo3xAFoBKCijen
SRBIY65yq5m00cWx3RMIcQq1B0X8nJS0O1P7MYE/Vvidz5St/36RXVu1oWLeS5Fx
HAezW0lqxEUzvC+uLTFWC6f/CilzmI7SsPAkZBk7dO5Qs0d7m/zWF588vlGS+0pt
D1on+lU85Ma2zuAd0qmB6LY66N8pEKKtMk93wF/o4Z5i58ahbwNvTKAzz4JSRWSu
mB9LAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcD
AQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEA
upU0DjzvIvoCOYKbq1RRN7rPdYad39mfjxgkeV0iOF5JoIdO6y1C7XAm9lT69Wjm
iUPvnCTMDYft40N2SvmXuuMaPOm4zjNwn4K33jw5XBnpwxC7By/Y0oV+Sl10fBsd
QqXC6H7LcSGkv+4eJbgY33P4uH5ZAy+2TkHUuZDkpufkAshzBust7nDAjfv3AIuQ
wlPoyZfI11eqyiOqRzOq+B5dIBr1JzKnEzSL6n0JLNQiPO7iN03rud/wYD3gbmcv
rzFL1KZfz+HZdnFwFW2T2gVW8L3ii1l9AJDuKzlvjUH3p6bgihVq02sjT8mx+GM2
7R4IbHGnj0BJA2vMYC4hSw==
-----END CERTIFICATE-----
`
const issuerMock = `-----BEGIN CERTIFICATE-----
MIIDDDCCAfSgAwIBAgIIOV5hkYJx0JwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
AxMVUGViYmxlIFJvb3QgQ0EgNTBmZmJkMB4XDTE4MTEwNzE3NDY0N1oXDTQ4MTEw
NzE3NDY0N1owKDEmMCQGA1UEAxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSAzOTVl
NjEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCacwXN4LmyRTgYS8TT
SZYgz758npHiPTBDKgeN5WVmkkwW0TuN4W2zXhEmcM82uxOEjWS2drvK0+iJKneh
0fQR8ZF35dIYFe8WXTg3kEmqcizSgh4LxlOntsXvatfX/6GU/ADo3xAFoBKCijen
SRBIY65yq5m00cWx3RMIcQq1B0X8nJS0O1P7MYE/Vvidz5St/36RXVu1oWLeS5Fx
HAezW0lqxEUzvC+uLTFWC6f/CilzmI7SsPAkZBk7dO5Qs0d7m/zWF588vlGS+0pt
D1on+lU85Ma2zuAd0qmB6LY66N8pEKKtMk93wF/o4Z5i58ahbwNvTKAzz4JSRWSu
mB9LAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcD
AQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEA
upU0DjzvIvoCOYKbq1RRN7rPdYad39mfjxgkeV0iOF5JoIdO6y1C7XAm9lT69Wjm
iUPvnCTMDYft40N2SvmXuuMaPOm4zjNwn4K33jw5XBnpwxC7By/Y0oV+Sl10fBsd
QqXC6H7LcSGkv+4eJbgY33P4uH5ZAy+2TkHUuZDkpufkAshzBust7nDAjfv3AIuQ
wlPoyZfI11eqyiOqRzOq+B5dIBr1JzKnEzSL6n0JLNQiPO7iN03rud/wYD3gbmcv
rzFL1KZfz+HZdnFwFW2T2gVW8L3ii1l9AJDuKzlvjUH3p6bgihVq02sjT8mx+GM2
7R4IbHGnj0BJA2vMYC4hSw==
-----END CERTIFICATE-----
`
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
}
})
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)
require.NoError(t, err)
cert, issuer, err := core.Certificates.Get(apiURL+"/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
}
})
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)
require.NoError(t, err)
cert, issuer, err := core.Certificates.Get(apiURL+"/certificate", true)
require.NoError(t, err)
assert.Equal(t, certResponseMock, string(cert), "Certificate")
assert.Equal(t, issuerMock, string(issuer), "IssuerCertificate")
}

View file

@ -1,45 +0,0 @@
package api
import (
"errors"
"github.com/go-acme/lego/v4/acme"
)
type ChallengeService service
// New Creates a challenge.
func (c *ChallengeService) New(chlgURL string) (acme.ExtendedChallenge, error) {
if chlgURL == "" {
return acme.ExtendedChallenge{}, errors.New("challenge[new]: empty URL")
}
// 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
}
chlng.AuthorizationURL = getLink(resp.Header, "up")
chlng.RetryAfter = getRetryAfter(resp)
return chlng, nil
}
// Get Gets a challenge.
func (c *ChallengeService) Get(chlgURL string) (acme.ExtendedChallenge, error) {
if chlgURL == "" {
return acme.ExtendedChallenge{}, errors.New("challenge[get]: empty URL")
}
var chlng acme.ExtendedChallenge
resp, err := c.core.postAsGet(chlgURL, &chlng)
if err != nil {
return acme.ExtendedChallenge{}, err
}
chlng.AuthorizationURL = getLink(resp.Header, "up")
chlng.RetryAfter = getRetryAfter(resp)
return chlng, nil
}

View file

@ -1,78 +0,0 @@
package nonces
import (
"errors"
"fmt"
"net/http"
"sync"
"github.com/go-acme/lego/v4/acme/api/internal/sender"
)
// Manager Manages nonces.
type Manager struct {
do *sender.Doer
nonceURL string
nonces []string
sync.Mutex
}
// NewManager Creates a new Manager.
func NewManager(do *sender.Doer, nonceURL string) *Manager {
return &Manager{
do: do,
nonceURL: nonceURL,
}
}
// Pop Pops a nonce.
func (n *Manager) Pop() (string, bool) {
n.Lock()
defer n.Unlock()
if len(n.nonces) == 0 {
return "", false
}
nonce := n.nonces[len(n.nonces)-1]
n.nonces = n.nonces[:len(n.nonces)-1]
return nonce, true
}
// Push Pushes a nonce.
func (n *Manager) Push(nonce string) {
n.Lock()
defer n.Unlock()
n.nonces = append(n.nonces, nonce)
}
// Nonce implement jose.NonceSource.
func (n *Manager) Nonce() (string, error) {
if nonce, ok := n.Pop(); ok {
return nonce, nil
}
return n.getNonce()
}
func (n *Manager) getNonce() (string, error) {
resp, err := n.do.Head(n.nonceURL)
if err != nil {
return "", fmt.Errorf("failed to get nonce from HTTP HEAD: %w", err)
}
return GetFromResponse(resp)
}
// GetFromResponse Extracts a nonce from a HTTP response.
func GetFromResponse(resp *http.Response) (string, error) {
if resp == nil {
return "", errors.New("nil response")
}
nonce := resp.Header.Get("Replay-Nonce")
if nonce == "" {
return "", errors.New("server did not respond with a proper nonce header")
}
return nonce, nil
}

View file

@ -1,55 +0,0 @@
package nonces
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"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"
)
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)
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()
if errN != nil {
t.Log(errN)
}
ch <- true
}()
go func() {
_, errN := j.Nonce()
if errN != nil {
t.Log(errN)
}
ch <- true
}()
go func() {
<-ch
<-ch
resultCh <- true
}()
select {
case <-resultCh:
case <-time.After(500 * time.Millisecond):
t.Fatal("JWS is probably holding a lock while making HTTP request")
}
}

View file

@ -1,130 +0,0 @@
package secure
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"encoding/base64"
"fmt"
"github.com/go-acme/lego/v4/acme/api/internal/nonces"
jose "github.com/go-jose/go-jose/v3"
)
// JWS Represents a JWS.
type JWS struct {
privKey crypto.PrivateKey
kid string // Key identifier
nonces *nonces.Manager
}
// NewJWS Create a new JWS.
func NewJWS(privateKey crypto.PrivateKey, kid string, nonceManager *nonces.Manager) *JWS {
return &JWS{
privKey: privateKey,
nonces: nonceManager,
kid: kid,
}
}
// SetKid Sets a key identifier.
func (j *JWS) SetKid(kid string) {
j.kid = kid
}
// 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
case *ecdsa.PrivateKey:
if k.Curve == elliptic.P256() {
alg = jose.ES256
} else if k.Curve == elliptic.P384() {
alg = jose.ES384
}
}
signKey := jose.SigningKey{
Algorithm: alg,
Key: jose.JSONWebKey{Key: j.privKey, KeyID: j.kid},
}
options := jose.SignerOptions{
NonceSource: j.nonces,
ExtraHeaders: map[jose.HeaderKey]interface{}{
"url": url,
},
}
if j.kid == "" {
options.EmbedJWK = true
}
signer, err := jose.NewSigner(signKey, &options)
if err != nil {
return nil, fmt.Errorf("failed to create jose signer: %w", err)
}
signed, err := signer.Sign(content)
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)
}
signer, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.HS256, Key: hmac},
&jose.SignerOptions{
EmbedJWK: false,
ExtraHeaders: map[jose.HeaderKey]interface{}{
"kid": kid,
"url": url,
},
},
)
if err != nil {
return nil, fmt.Errorf("failed to create External Account Binding jose signer: %w", err)
}
signed, err := signer.Sign(jwkJSON)
if err != nil {
return nil, fmt.Errorf("failed to External Account Binding sign content: %w", err)
}
return signed, nil
}
// 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()
case *rsa.PrivateKey:
publicKey = k.Public()
}
// Generate the Key Authorization for the challenge
jwk := &jose.JSONWebKey{Key: publicKey}
thumbBytes, err := jwk.Thumbprint(crypto.SHA256)
if err != nil {
return "", err
}
// unpad the base64URL
keyThumb := base64.RawURLEncoding.EncodeToString(thumbBytes)
return token + "." + keyThumb, nil
}

View file

@ -1,56 +0,0 @@
package secure
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"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"
)
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)
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()
if errN != nil {
t.Log(errN)
}
ch <- true
}()
go func() {
_, errN := j.Nonce()
if errN != nil {
t.Log(errN)
}
ch <- true
}()
go func() {
<-ch
<-ch
resultCh <- true
}()
select {
case <-resultCh:
case <-time.After(500 * time.Millisecond):
t.Fatal("JWS is probably holding a lock while making HTTP request")
}
}

View file

@ -1,148 +0,0 @@
package sender
import (
"encoding/json"
"fmt"
"io"
"net/http"
"runtime"
"strings"
"github.com/go-acme/lego/v4/acme"
)
type RequestOption func(*http.Request) error
func contentType(ct string) RequestOption {
return func(req *http.Request) error {
req.Header.Set("Content-Type", ct)
return nil
}
}
type Doer struct {
httpClient *http.Client
userAgent string
}
// NewDoer Creates a new Doer.
func NewDoer(client *http.Client, userAgent string) *Doer {
return &Doer{
httpClient: client,
userAgent: userAgent,
}
}
// 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) {
req, err := d.newRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return d.do(req, response)
}
// Head performs a HEAD request with a proper User-Agent string.
// The response body (resp.Body) is already closed when this function returns.
func (d *Doer) Head(url string) (*http.Response, error) {
req, err := d.newRequest(http.MethodHead, url, nil)
if err != nil {
return nil, err
}
return d.do(req, nil)
}
// 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) {
req, err := d.newRequest(http.MethodPost, url, body, contentType(bodyType))
if err != nil {
return nil, err
}
return d.do(req, response)
}
func (d *Doer) newRequest(method, uri string, body io.Reader, opts ...RequestOption) (*http.Request, error) {
req, err := http.NewRequest(method, uri, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", d.formatUserAgent())
for _, opt := range opts {
err = opt(req)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
}
return req, nil
}
func (d *Doer) do(req *http.Request, response interface{}) (*http.Response, error) {
resp, err := d.httpClient.Do(req)
if err != nil {
return nil, err
}
if err = checkError(req, resp); err != nil {
return resp, err
}
if response != nil {
raw, err := io.ReadAll(resp.Body)
if err != nil {
return resp, err
}
defer resp.Body.Close()
err = json.Unmarshal(raw, response)
if err != nil {
return resp, fmt.Errorf("failed to unmarshal %q to type %T: %w", raw, response, err)
}
}
return resp, nil
}
// formatUserAgent builds and returns the User-Agent string to use in requests.
func (d *Doer) formatUserAgent() string {
ua := fmt.Sprintf("%s %s (%s; %s; %s)", d.userAgent, ourUserAgent, ourUserAgentComment, runtime.GOOS, runtime.GOARCH)
return strings.TrimSpace(ua)
}
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}
}
return errorDetails
}
return nil
}

View file

@ -1,67 +0,0 @@
package sender
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"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) {
ua = r.Header.Get("User-Agent")
method = r.Method
}))
t.Cleanup(server.Close)
doer := NewDoer(http.DefaultClient, "")
testCases := []struct {
method string
call func(u string) (*http.Response, error)
}{
{
method: http.MethodGet,
call: func(u string) (*http.Response, error) {
return doer.Get(u, nil)
},
},
{
method: http.MethodHead,
call: doer.Head,
},
{
method: http.MethodPost,
call: func(u string) (*http.Response, error) {
return doer.Post(u, strings.NewReader("falalalala"), "text/plain", nil)
},
},
}
for _, test := range testCases {
t.Run(test.method, func(t *testing.T) {
_, err := test.call(server.URL)
require.NoError(t, err)
assert.Equal(t, test.method, method)
assert.Contains(t, ua, ourUserAgent, "User-Agent")
})
}
}
func TestDo_CustomUserAgent(t *testing.T) {
customUA := "MyApp/1.2.3"
doer := NewDoer(http.DefaultClient, customUA)
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)
}

View file

@ -1,14 +0,0 @@
package sender
// CODE GENERATED AUTOMATICALLY
// THIS FILE MUST NOT BE EDITED BY HAND
const (
// ourUserAgent is the User-Agent of this underlying library package.
ourUserAgent = "xenolf-acme/4.13.2"
// 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 = "detach"
)

View file

@ -1,94 +0,0 @@
package api
import (
"encoding/base64"
"errors"
"net"
"time"
"github.com/go-acme/lego/v4/acme"
)
// OrderOptions used to create an order (optional).
type OrderOptions struct {
NotBefore time.Time
NotAfter time.Time
}
type OrderService service
// New Creates a new order.
func (o *OrderService) New(domains []string) (acme.ExtendedOrder, error) {
return o.NewWithOptions(domains, nil)
}
// 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}
if opts != nil {
if !opts.NotAfter.IsZero() {
orderReq.NotAfter = opts.NotAfter.Format(time.RFC3339)
}
if !opts.NotBefore.IsZero() {
orderReq.NotBefore = opts.NotBefore.Format(time.RFC3339)
}
}
var order acme.Order
resp, err := o.core.post(o.core.GetDirectory().NewOrderURL, orderReq, &order)
if err != nil {
return acme.ExtendedOrder{}, err
}
return acme.ExtendedOrder{
Order: order,
Location: resp.Header.Get("Location"),
}, nil
}
// Get Gets an order.
func (o *OrderService) Get(orderURL string) (acme.ExtendedOrder, error) {
if orderURL == "" {
return acme.ExtendedOrder{}, errors.New("order[get]: empty URL")
}
var order acme.Order
_, err := o.core.postAsGet(orderURL, &order)
if err != nil {
return acme.ExtendedOrder{}, err
}
return acme.ExtendedOrder{Order: order}, nil
}
// UpdateForCSR Updates an order for a CSR.
func (o *OrderService) UpdateForCSR(orderURL string, csr []byte) (acme.ExtendedOrder, error) {
csrMsg := acme.CSRMessage{
Csr: base64.RawURLEncoding.EncodeToString(csr),
}
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{Order: order}, nil
}

View file

@ -1,129 +0,0 @@
package api
import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"io"
"net/http"
"testing"
"time"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/platform/tester"
"github.com/go-jose/go-jose/v3"
"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)
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
}
body, err := readSignedBody(r, privateKey)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
order := acme.Order{}
err = json.Unmarshal(body, &order)
if err != nil {
http.Error(w, 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
}
})
core, err := New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
require.NoError(t, err)
testCases := []struct {
desc string
opts *OrderOptions
expected acme.ExtendedOrder
}{
{
desc: "simple",
expected: acme.ExtendedOrder{
Order: acme.Order{
Status: "valid",
Identifiers: []acme.Identifier{{Type: "dns", Value: "example.com"}},
},
},
},
{
desc: "with options",
opts: &OrderOptions{
NotBefore: time.Date(2023, 1, 1, 1, 0, 0, 0, time.UTC),
NotAfter: time.Date(2023, 1, 2, 1, 0, 0, 0, time.UTC),
},
expected: acme.ExtendedOrder{
Order: acme.Order{
Status: "valid",
Identifiers: []acme.Identifier{{Type: "dns", Value: "example.com"}},
NotBefore: "2023-01-01T01:00:00Z",
NotAfter: "2023-01-02T01:00:00Z",
},
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
order, err := core.Orders.NewWithOptions([]string{"example.com"}, test.opts)
require.NoError(t, err)
assert.Equal(t, test.expected, order)
})
}
}
func readSignedBody(r *http.Request, privateKey *rsa.PrivateKey) ([]byte, error) {
reqBody, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
jws, err := jose.ParseSigned(string(reqBody))
if err != nil {
return nil, err
}
body, err := jws.Verify(&jose.JSONWebKey{
Key: privateKey.Public(),
Algorithm: "RSA",
})
if err != nil {
return nil, err
}
return body, nil
}

View file

@ -1,53 +0,0 @@
package api
import (
"errors"
"net/http"
"github.com/go-acme/lego/v4/acme"
)
// ErrNoARI is returned when the server does not advertise a renewal info endpoint.
var ErrNoARI = errors.New("renewalInfo[get/post]: server does not advertise a renewal info endpoint")
// GetRenewalInfo GETs renewal information for a certificate from the renewalInfo endpoint.
// This is used to determine if a certificate needs to be renewed.
//
// 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
func (c *CertificateService) GetRenewalInfo(certID string) (*http.Response, error) {
if c.core.GetDirectory().RenewalInfo == "" {
return nil, ErrNoARI
}
if certID == "" {
return nil, errors.New("renewalInfo[get]: 'certID' cannot be empty")
}
return c.core.HTTPClient.Get(c.core.GetDirectory().RenewalInfo + "/" + certID)
}
// UpdateRenewalInfo POSTs updated renewal information for a certificate to the renewalInfo endpoint.
// This is used to indicate that a certificate has been replaced.
//
// 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
func (c *CertificateService) UpdateRenewalInfo(req acme.RenewalInfoUpdateRequest) (*http.Response, error) {
if c.core.GetDirectory().RenewalInfo == "" {
return nil, ErrNoARI
}
if req.CertID == "" {
return nil, errors.New("renewalInfo[post]: 'certID' cannot be empty")
}
if !req.Replaced {
return nil, errors.New("renewalInfo[post]: 'replaced' cannot be false")
}
return c.core.post(c.core.GetDirectory().RenewalInfo, req, nil)
}

View file

@ -1,56 +0,0 @@
package api
import (
"net/http"
"regexp"
)
type service struct {
core *Core
}
// getLink get a rel into the Link header.
func getLink(header http.Header, rel string) string {
links := getLinks(header, rel)
if len(links) < 1 {
return ""
}
return links[0]
}
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])
}
}
}
return links
}
// getLocation get the value of the header Location.
func getLocation(resp *http.Response) string {
if resp == nil {
return ""
}
return resp.Header.Get("Location")
}
// getRetryAfter get the value of the header Retry-After.
func getRetryAfter(resp *http.Response) string {
if resp == nil {
return ""
}
return resp.Header.Get("Retry-After")
}

View file

@ -1,56 +0,0 @@
package api
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_getLink(t *testing.T) {
testCases := []struct {
desc string
header http.Header
relName string
expected string
}{
{
desc: "success",
header: http.Header{
"Link": []string{`<https://acme-staging-v02.api.letsencrypt.org/next>; rel="next", <https://acme-staging-v02.api.letsencrypt.org/up?query>; rel="up"`},
},
relName: "up",
expected: "https://acme-staging-v02.api.letsencrypt.org/up?query",
},
{
desc: "success several lines",
header: http.Header{
"Link": []string{`<https://acme-staging-v02.api.letsencrypt.org/next>; rel="next"`, `<https://acme-staging-v02.api.letsencrypt.org/up?query>; rel="up"`},
},
relName: "up",
expected: "https://acme-staging-v02.api.letsencrypt.org/up?query",
},
{
desc: "no link",
header: http.Header{},
relName: "up",
expected: "",
},
{
desc: "no header",
relName: "up",
expected: "",
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
link := getLink(test.header, test.relName)
assert.Equal(t, test.expected, link)
})
}
}

16
acme/challenges.go Normal file
View file

@ -0,0 +1,16 @@
package acme
// Challenge is a string that identifies a particular type and version of ACME challenge.
type Challenge string
const (
// HTTP01 is the "http-01" ACME challenge https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md#http
// Note: HTTP01ChallengePath returns the URL path to fulfill this challenge
HTTP01 = Challenge("http-01")
// TLSSNI01 is the "tls-sni-01" ACME challenge https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md#tls-with-server-name-indication-tls-sni
// Note: TLSSNI01ChallengeCert returns a certificate to fulfill this challenge
TLSSNI01 = Challenge("tls-sni-01")
// DNS01 is the "dns-01" ACME challenge https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md#dns
// Note: DNS01Record returns a DNS record which will fulfill this challenge
DNS01 = Challenge("dns-01")
)

640
acme/client.go Normal file
View file

@ -0,0 +1,640 @@
// Package acme implements the ACME protocol for Let's Encrypt and other conforming providers.
package acme
import (
"crypto"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"net"
"regexp"
"strconv"
"strings"
"time"
)
var (
// Logger is an optional custom logger.
Logger *log.Logger
)
// logf writes a log entry. It uses Logger if not
// nil, otherwise it uses the default log.Logger.
func logf(format string, args ...interface{}) {
if Logger != nil {
Logger.Printf(format, args...)
} else {
log.Printf(format, args...)
}
}
// User interface is to be implemented by users of this library.
// It is used by the client type to get user specific information.
type User interface {
GetEmail() string
GetRegistration() *RegistrationResource
GetPrivateKey() crypto.PrivateKey
}
// Interface for all challenge solvers to implement.
type solver interface {
Solve(challenge challenge, domain string) error
}
type validateFunc func(j *jws, domain, uri string, chlng challenge) error
// Client is the user-friendy way to ACME
type Client struct {
directory directory
user User
jws *jws
keyType KeyType
issuerCert []byte
solvers map[Challenge]solver
}
// NewClient creates a new ACME client on behalf of the user. The client will depend on
// the ACME directory located at caDirURL for the rest of its actions. It will
// generate private keys for certificates of size keyBits.
func NewClient(caDirURL string, user User, keyType KeyType) (*Client, error) {
privKey := user.GetPrivateKey()
if privKey == nil {
return nil, errors.New("private key was nil")
}
var dir directory
if _, err := getJSON(caDirURL, &dir); err != nil {
return nil, fmt.Errorf("get directory at '%s': %v", caDirURL, err)
}
if dir.NewRegURL == "" {
return nil, errors.New("directory missing new registration URL")
}
if dir.NewAuthzURL == "" {
return nil, errors.New("directory missing new authz URL")
}
if dir.NewCertURL == "" {
return nil, errors.New("directory missing new certificate URL")
}
if dir.RevokeCertURL == "" {
return nil, errors.New("directory missing revoke certificate URL")
}
jws := &jws{privKey: privKey, directoryURL: caDirURL}
// REVIEW: best possibility?
// Add all available solvers with the right index as per ACME
// spec to this map. Otherwise they won`t be found.
solvers := make(map[Challenge]solver)
solvers[HTTP01] = &httpChallenge{jws: jws, validate: validate, provider: &HTTPProviderServer{}}
solvers[TLSSNI01] = &tlsSNIChallenge{jws: jws, validate: validate, provider: &TLSProviderServer{}}
return &Client{directory: dir, user: user, jws: jws, keyType: keyType, solvers: solvers}, nil
}
// SetChallengeProvider specifies a custom provider that will make the solution available
func (c *Client) SetChallengeProvider(challenge Challenge, p ChallengeProvider) error {
switch challenge {
case HTTP01:
c.solvers[challenge] = &httpChallenge{jws: c.jws, validate: validate, provider: p}
case TLSSNI01:
c.solvers[challenge] = &tlsSNIChallenge{jws: c.jws, validate: validate, provider: p}
case DNS01:
c.solvers[challenge] = &dnsChallenge{jws: c.jws, validate: validate, provider: p}
default:
return fmt.Errorf("Unknown challenge %v", challenge)
}
return nil
}
// SetHTTPAddress specifies a custom interface:port to be used for HTTP based challenges.
// If this option is not used, the default port 80 and all interfaces will be used.
// To only specify a port and no interface use the ":port" notation.
func (c *Client) SetHTTPAddress(iface string) error {
host, port, err := net.SplitHostPort(iface)
if err != nil {
return err
}
if chlng, ok := c.solvers[HTTP01]; ok {
chlng.(*httpChallenge).provider = NewHTTPProviderServer(host, port)
}
return nil
}
// SetTLSAddress specifies a custom interface:port to be used for TLS based challenges.
// If this option is not used, the default port 443 and all interfaces will be used.
// To only specify a port and no interface use the ":port" notation.
func (c *Client) SetTLSAddress(iface string) error {
host, port, err := net.SplitHostPort(iface)
if err != nil {
return err
}
if chlng, ok := c.solvers[TLSSNI01]; ok {
chlng.(*tlsSNIChallenge).provider = NewTLSProviderServer(host, port)
}
return nil
}
// ExcludeChallenges explicitly removes challenges from the pool for solving.
func (c *Client) ExcludeChallenges(challenges []Challenge) {
// Loop through all challenges and delete the requested one if found.
for _, challenge := range challenges {
delete(c.solvers, challenge)
}
}
// Register the current account to the ACME server.
func (c *Client) Register() (*RegistrationResource, error) {
if c == nil || c.user == nil {
return nil, errors.New("acme: cannot register a nil client or user")
}
logf("[INFO] acme: Registering account for %s", c.user.GetEmail())
regMsg := registrationMessage{
Resource: "new-reg",
}
if c.user.GetEmail() != "" {
regMsg.Contact = []string{"mailto:" + c.user.GetEmail()}
} else {
regMsg.Contact = []string{}
}
var serverReg Registration
hdr, err := postJSON(c.jws, c.directory.NewRegURL, regMsg, &serverReg)
if err != nil {
return nil, err
}
reg := &RegistrationResource{Body: serverReg}
links := parseLinks(hdr["Link"])
reg.URI = hdr.Get("Location")
if links["terms-of-service"] != "" {
reg.TosURL = links["terms-of-service"]
}
if links["next"] != "" {
reg.NewAuthzURL = links["next"]
} else {
return nil, errors.New("acme: The server did not return 'next' link to proceed")
}
return reg, nil
}
// AgreeToTOS updates the Client registration and sends the agreement to
// the server.
func (c *Client) AgreeToTOS() error {
reg := c.user.GetRegistration()
reg.Body.Agreement = c.user.GetRegistration().TosURL
reg.Body.Resource = "reg"
_, err := postJSON(c.jws, c.user.GetRegistration().URI, c.user.GetRegistration().Body, nil)
return err
}
// ObtainCertificate tries to obtain a single certificate using all domains passed into it.
// The first domain in domains is used for the CommonName field of the certificate, all other
// domains are added using the Subject Alternate Names extension. A new private key is generated
// for every invocation of this function. If you do not want that you can supply your own private key
// in the privKey parameter. If this parameter is non-nil it will be used instead of generating a new one.
// If bundle is true, the []byte contains both the issuer certificate and
// your issued certificate as a bundle.
// This function will never return a partial certificate. If one domain in the list fails,
// the whole certificate will fail.
func (c *Client) ObtainCertificate(domains []string, bundle bool, privKey crypto.PrivateKey) (CertificateResource, map[string]error) {
if bundle {
logf("[INFO][%s] acme: Obtaining bundled SAN certificate", strings.Join(domains, ", "))
} else {
logf("[INFO][%s] acme: Obtaining SAN certificate", strings.Join(domains, ", "))
}
challenges, failures := c.getChallenges(domains)
// If any challenge fails - return. Do not generate partial SAN certificates.
if len(failures) > 0 {
return CertificateResource{}, failures
}
errs := c.solveChallenges(challenges)
// If any challenge fails - return. Do not generate partial SAN certificates.
if len(errs) > 0 {
return CertificateResource{}, errs
}
logf("[INFO][%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", "))
cert, err := c.requestCertificate(challenges, bundle, privKey)
if err != nil {
for _, chln := range challenges {
failures[chln.Domain] = err
}
}
return cert, failures
}
// RevokeCertificate takes a PEM encoded certificate or bundle and tries to revoke it at the CA.
func (c *Client) RevokeCertificate(certificate []byte) error {
certificates, err := parsePEMBundle(certificate)
if err != nil {
return err
}
x509Cert := certificates[0]
if x509Cert.IsCA {
return fmt.Errorf("Certificate bundle starts with a CA certificate")
}
encodedCert := base64.URLEncoding.EncodeToString(x509Cert.Raw)
_, err = postJSON(c.jws, c.directory.RevokeCertURL, revokeCertMessage{Resource: "revoke-cert", Certificate: encodedCert}, nil)
return err
}
// RenewCertificate takes a CertificateResource and tries to renew the certificate.
// If the renewal process succeeds, the new certificate will ge returned in a new CertResource.
// Please be aware that this function will return a new certificate in ANY case that is not an error.
// If the server does not provide us with a new cert on a GET request to the CertURL
// this function will start a new-cert flow where a new certificate gets generated.
// 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 CertificateResource should be non-nil.
func (c *Client) RenewCertificate(cert CertificateResource, bundle bool) (CertificateResource, error) {
// Input certificate is PEM encoded. Decode it here as we may need the decoded
// cert later on in the renewal process. The input may be a bundle or a single certificate.
certificates, err := parsePEMBundle(cert.Certificate)
if err != nil {
return CertificateResource{}, err
}
x509Cert := certificates[0]
if x509Cert.IsCA {
return CertificateResource{}, fmt.Errorf("[%s] Certificate bundle starts with a CA certificate", cert.Domain)
}
// This is just meant to be informal for the user.
timeLeft := x509Cert.NotAfter.Sub(time.Now().UTC())
logf("[INFO][%s] acme: Trying renewal with %d hours remaining", cert.Domain, int(timeLeft.Hours()))
// The first step of renewal is to check if we get a renewed cert
// directly from the cert URL.
resp, err := httpGet(cert.CertURL)
if err != nil {
return CertificateResource{}, err
}
defer resp.Body.Close()
serverCertBytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
return CertificateResource{}, err
}
serverCert, err := x509.ParseCertificate(serverCertBytes)
if err != nil {
return CertificateResource{}, err
}
// If the server responds with a different certificate we are effectively renewed.
// TODO: Further test if we can actually use the new certificate (Our private key works)
if !x509Cert.Equal(serverCert) {
logf("[INFO][%s] acme: Server responded with renewed certificate", cert.Domain)
issuedCert := pemEncode(derCertificateBytes(serverCertBytes))
// If bundle is true, we want to return a certificate bundle.
// To do this, we need the issuer certificate.
if bundle {
// The issuer certificate link is always supplied via an "up" link
// in the response headers of a new certificate.
links := parseLinks(resp.Header["Link"])
issuerCert, err := c.getIssuerCertificate(links["up"])
if err != nil {
// If we fail to acquire the issuer cert, return the issued certificate - do not fail.
logf("[ERROR][%s] acme: Could not bundle issuer certificate: %v", cert.Domain, err)
} else {
// Success - append the issuer cert to the issued cert.
issuerCert = pemEncode(derCertificateBytes(issuerCert))
issuedCert = append(issuedCert, issuerCert...)
}
}
cert.Certificate = issuedCert
return cert, nil
}
var privKey crypto.PrivateKey
if cert.PrivateKey != nil {
privKey, err = parsePEMPrivateKey(cert.PrivateKey)
if err != nil {
return CertificateResource{}, err
}
}
var domains []string
var failures map[string]error
// check for SAN certificate
if len(x509Cert.DNSNames) > 1 {
domains = append(domains, x509Cert.Subject.CommonName)
for _, sanDomain := range x509Cert.DNSNames {
if sanDomain == x509Cert.Subject.CommonName {
continue
}
domains = append(domains, sanDomain)
}
} else {
domains = append(domains, x509Cert.Subject.CommonName)
}
newCert, failures := c.ObtainCertificate(domains, bundle, privKey)
return newCert, failures[cert.Domain]
}
// Looks through the challenge combinations to find a solvable match.
// Then solves the challenges in series and returns.
func (c *Client) solveChallenges(challenges []authorizationResource) map[string]error {
// loop through the resources, basically through the domains.
failures := make(map[string]error)
for _, authz := range challenges {
// no solvers - no solving
if solvers := c.chooseSolvers(authz.Body, authz.Domain); solvers != nil {
for i, solver := range solvers {
// TODO: do not immediately fail if one domain fails to validate.
err := solver.Solve(authz.Body.Challenges[i], authz.Domain)
if err != nil {
failures[authz.Domain] = err
}
}
} else {
failures[authz.Domain] = fmt.Errorf("[%s] acme: Could not determine solvers", authz.Domain)
}
}
return failures
}
// Checks all combinations from the server and returns an array of
// solvers which should get executed in series.
func (c *Client) chooseSolvers(auth authorization, domain string) map[int]solver {
for _, combination := range auth.Combinations {
solvers := make(map[int]solver)
for _, idx := range combination {
if solver, ok := c.solvers[auth.Challenges[idx].Type]; ok {
solvers[idx] = solver
} else {
logf("[INFO][%s] acme: Could not find solver for: %s", domain, auth.Challenges[idx].Type)
}
}
// If we can solve the whole combination, return the solvers
if len(solvers) == len(combination) {
return solvers
}
}
return nil
}
// Get the challenges needed to proof our identifier to the ACME server.
func (c *Client) getChallenges(domains []string) ([]authorizationResource, map[string]error) {
resc, errc := make(chan authorizationResource), make(chan domainError)
for _, domain := range domains {
go func(domain string) {
authMsg := authorization{Resource: "new-authz", Identifier: identifier{Type: "dns", Value: domain}}
var authz authorization
hdr, err := postJSON(c.jws, c.user.GetRegistration().NewAuthzURL, authMsg, &authz)
if err != nil {
errc <- domainError{Domain: domain, Error: err}
return
}
links := parseLinks(hdr["Link"])
if links["next"] == "" {
logf("[ERROR][%s] acme: Server did not provide next link to proceed", domain)
return
}
resc <- authorizationResource{Body: authz, NewCertURL: links["next"], AuthURL: hdr.Get("Location"), Domain: domain}
}(domain)
}
responses := make(map[string]authorizationResource)
failures := make(map[string]error)
for i := 0; i < len(domains); i++ {
select {
case res := <-resc:
responses[res.Domain] = res
case err := <-errc:
failures[err.Domain] = err.Error
}
}
challenges := make([]authorizationResource, 0, len(responses))
for _, domain := range domains {
if challenge, ok := responses[domain]; ok {
challenges = append(challenges, challenge)
}
}
close(resc)
close(errc)
return challenges, failures
}
func (c *Client) requestCertificate(authz []authorizationResource, bundle bool, privKey crypto.PrivateKey) (CertificateResource, error) {
if len(authz) == 0 {
return CertificateResource{}, errors.New("Passed no authorizations to requestCertificate!")
}
commonName := authz[0]
var err error
if privKey == nil {
privKey, err = generatePrivateKey(c.keyType)
if err != nil {
return CertificateResource{}, err
}
}
var san []string
var authURLs []string
for _, auth := range authz[1:] {
san = append(san, auth.Domain)
authURLs = append(authURLs, auth.AuthURL)
}
// TODO: should the CSR be customizable?
csr, err := generateCsr(privKey, commonName.Domain, san)
if err != nil {
return CertificateResource{}, err
}
csrString := base64.URLEncoding.EncodeToString(csr)
jsonBytes, err := json.Marshal(csrMessage{Resource: "new-cert", Csr: csrString, Authorizations: authURLs})
if err != nil {
return CertificateResource{}, err
}
resp, err := c.jws.post(commonName.NewCertURL, jsonBytes)
if err != nil {
return CertificateResource{}, err
}
privateKeyPem := pemEncode(privKey)
cerRes := CertificateResource{
Domain: commonName.Domain,
CertURL: resp.Header.Get("Location"),
PrivateKey: privateKeyPem}
for {
switch resp.StatusCode {
case 201, 202:
cert, err := ioutil.ReadAll(limitReader(resp.Body, 1024*1024))
resp.Body.Close()
if err != nil {
return CertificateResource{}, err
}
// The server returns a body with a length of zero if the
// certificate was not ready at the time this request completed.
// Otherwise the body is the certificate.
if len(cert) > 0 {
cerRes.CertStableURL = resp.Header.Get("Content-Location")
cerRes.AccountRef = c.user.GetRegistration().URI
issuedCert := pemEncode(derCertificateBytes(cert))
// If bundle is true, we want to return a certificate bundle.
// To do this, we need the issuer certificate.
if bundle {
// The issuer certificate link is always supplied via an "up" link
// in the response headers of a new certificate.
links := parseLinks(resp.Header["Link"])
issuerCert, err := c.getIssuerCertificate(links["up"])
if err != nil {
// If we fail to acquire the issuer cert, return the issued certificate - do not fail.
logf("[WARNING][%s] acme: Could not bundle issuer certificate: %v", commonName.Domain, err)
} else {
// Success - append the issuer cert to the issued cert.
issuerCert = pemEncode(derCertificateBytes(issuerCert))
issuedCert = append(issuedCert, issuerCert...)
}
}
cerRes.Certificate = issuedCert
logf("[INFO][%s] Server responded with a certificate.", commonName.Domain)
return cerRes, nil
}
// The certificate was granted but is not yet issued.
// Check retry-after and loop.
ra := resp.Header.Get("Retry-After")
retryAfter, err := strconv.Atoi(ra)
if err != nil {
return CertificateResource{}, err
}
logf("[INFO][%s] acme: Server responded with status 202; retrying after %ds", commonName.Domain, retryAfter)
time.Sleep(time.Duration(retryAfter) * time.Second)
break
default:
return CertificateResource{}, handleHTTPError(resp)
}
resp, err = httpGet(cerRes.CertURL)
if err != nil {
return CertificateResource{}, err
}
}
}
// getIssuerCertificate requests the issuer certificate and caches it for
// subsequent requests.
func (c *Client) getIssuerCertificate(url string) ([]byte, error) {
logf("[INFO] acme: Requesting issuer cert from %s", url)
if c.issuerCert != nil {
return c.issuerCert, nil
}
resp, err := httpGet(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
issuerBytes, err := ioutil.ReadAll(limitReader(resp.Body, 1024*1024))
if err != nil {
return nil, err
}
_, err = x509.ParseCertificate(issuerBytes)
if err != nil {
return nil, err
}
c.issuerCert = issuerBytes
return issuerBytes, err
}
func parseLinks(links []string) map[string]string {
aBrkt := regexp.MustCompile("[<>]")
slver := regexp.MustCompile("(.+) *= *\"(.+)\"")
linkMap := make(map[string]string)
for _, link := range links {
link = aBrkt.ReplaceAllString(link, "")
parts := strings.Split(link, ";")
matches := slver.FindStringSubmatch(parts[1])
if len(matches) > 0 {
linkMap[matches[2]] = parts[0]
}
}
return linkMap
}
// validate makes the ACME server start validating a
// challenge response, only returning once it is done.
func validate(j *jws, domain, uri string, chlng challenge) error {
var challengeResponse challenge
hdr, err := postJSON(j, uri, chlng, &challengeResponse)
if err != nil {
return err
}
// After the path is sent, the ACME server will access our server.
// Repeatedly check the server for an updated status on our request.
for {
switch challengeResponse.Status {
case "valid":
logf("[INFO][%s] The server validated our request", domain)
return nil
case "pending":
break
case "invalid":
return handleChallengeError(challengeResponse)
default:
return errors.New("The server returned an unexpected state.")
}
ra, err := strconv.Atoi(hdr.Get("Retry-After"))
if err != nil {
// The ACME server MUST return a Retry-After.
// If it doesn't, we'll just poll hard.
ra = 1
}
time.Sleep(time.Duration(ra) * time.Second)
hdr, err = getJSON(uri, &challengeResponse)
if err != nil {
return err
}
}
}

198
acme/client_test.go Normal file
View file

@ -0,0 +1,198 @@
package acme
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNewClient(t *testing.T) {
keyBits := 32 // small value keeps test fast
keyType := RSA2048
key, err := rsa.GenerateKey(rand.Reader, keyBits)
if err != nil {
t.Fatal("Could not generate test key:", err)
}
user := mockUser{
email: "test@test.com",
regres: new(RegistrationResource),
privatekey: key,
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, _ := json.Marshal(directory{NewAuthzURL: "http://test", NewCertURL: "http://test", NewRegURL: "http://test", RevokeCertURL: "http://test"})
w.Write(data)
}))
client, err := NewClient(ts.URL, user, keyType)
if err != nil {
t.Fatalf("Could not create client: %v", err)
}
if client.jws == nil {
t.Fatalf("Expected client.jws to not be nil")
}
if expected, actual := key, client.jws.privKey; actual != expected {
t.Errorf("Expected jws.privKey to be %p but was %p", expected, actual)
}
if client.keyType != keyType {
t.Errorf("Expected keyType to be %s but was %s", keyType, client.keyType)
}
if expected, actual := 2, len(client.solvers); actual != expected {
t.Fatalf("Expected %d solver(s), got %d", expected, actual)
}
}
func TestClientOptPort(t *testing.T) {
keyBits := 32 // small value keeps test fast
key, err := rsa.GenerateKey(rand.Reader, keyBits)
if err != nil {
t.Fatal("Could not generate test key:", err)
}
user := mockUser{
email: "test@test.com",
regres: new(RegistrationResource),
privatekey: key,
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, _ := json.Marshal(directory{NewAuthzURL: "http://test", NewCertURL: "http://test", NewRegURL: "http://test", RevokeCertURL: "http://test"})
w.Write(data)
}))
optPort := "1234"
optHost := ""
client, err := NewClient(ts.URL, user, RSA2048)
if err != nil {
t.Fatalf("Could not create client: %v", err)
}
client.SetHTTPAddress(net.JoinHostPort(optHost, optPort))
client.SetTLSAddress(net.JoinHostPort(optHost, optPort))
httpSolver, ok := client.solvers[HTTP01].(*httpChallenge)
if !ok {
t.Fatal("Expected http-01 solver to be httpChallenge type")
}
if httpSolver.jws != client.jws {
t.Error("Expected http-01 to have same jws as client")
}
if got := httpSolver.provider.(*HTTPProviderServer).port; got != optPort {
t.Errorf("Expected http-01 to have port %s but was %s", optPort, got)
}
if got := httpSolver.provider.(*HTTPProviderServer).iface; got != optHost {
t.Errorf("Expected http-01 to have iface %s but was %s", optHost, got)
}
httpsSolver, ok := client.solvers[TLSSNI01].(*tlsSNIChallenge)
if !ok {
t.Fatal("Expected tls-sni-01 solver to be httpChallenge type")
}
if httpsSolver.jws != client.jws {
t.Error("Expected tls-sni-01 to have same jws as client")
}
if got := httpsSolver.provider.(*TLSProviderServer).port; got != optPort {
t.Errorf("Expected tls-sni-01 to have port %s but was %s", optPort, got)
}
if got := httpsSolver.provider.(*TLSProviderServer).iface; got != optHost {
t.Errorf("Expected tls-sni-01 to have port %s but was %s", optHost, got)
}
// test setting different host
optHost = "127.0.0.1"
client.SetHTTPAddress(net.JoinHostPort(optHost, optPort))
client.SetTLSAddress(net.JoinHostPort(optHost, optPort))
if got := httpSolver.provider.(*HTTPProviderServer).iface; got != optHost {
t.Errorf("Expected http-01 to have iface %s but was %s", optHost, got)
}
if got := httpsSolver.provider.(*TLSProviderServer).port; got != optPort {
t.Errorf("Expected tls-sni-01 to have port %s but was %s", optPort, got)
}
}
func TestValidate(t *testing.T) {
var statuses []string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Minimal stub ACME server for validation.
w.Header().Add("Replay-Nonce", "12345")
w.Header().Add("Retry-After", "0")
switch r.Method {
case "HEAD":
case "POST":
st := statuses[0]
statuses = statuses[1:]
writeJSONResponse(w, &challenge{Type: "http-01", Status: st, URI: "http://example.com/", Token: "token"})
case "GET":
st := statuses[0]
statuses = statuses[1:]
writeJSONResponse(w, &challenge{Type: "http-01", Status: st, URI: "http://example.com/", Token: "token"})
default:
http.Error(w, r.Method, http.StatusMethodNotAllowed)
}
}))
defer ts.Close()
privKey, _ := rsa.GenerateKey(rand.Reader, 512)
j := &jws{privKey: privKey, directoryURL: ts.URL}
tsts := []struct {
name string
statuses []string
want string
}{
{"POST-unexpected", []string{"weird"}, "unexpected"},
{"POST-valid", []string{"valid"}, ""},
{"POST-invalid", []string{"invalid"}, "Error Detail"},
{"GET-unexpected", []string{"pending", "weird"}, "unexpected"},
{"GET-valid", []string{"pending", "valid"}, ""},
{"GET-invalid", []string{"pending", "invalid"}, "Error Detail"},
}
for _, tst := range tsts {
statuses = tst.statuses
if err := validate(j, "example.com", ts.URL, challenge{Type: "http-01", Token: "token"}); err == nil && tst.want != "" {
t.Errorf("[%s] validate: got error %v, want something with %q", tst.name, err, tst.want)
} else if err != nil && !strings.Contains(err.Error(), tst.want) {
t.Errorf("[%s] validate: got error %v, want something with %q", tst.name, err, tst.want)
}
}
}
// writeJSONResponse marshals the body as JSON and writes it to the response.
func writeJSONResponse(w http.ResponseWriter, body interface{}) {
bs, err := json.Marshal(body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(bs); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// stubValidate is like validate, except it does nothing.
func stubValidate(j *jws, domain, uri string, chlng challenge) error {
return nil
}
type mockUser struct {
email string
regres *RegistrationResource
privatekey *rsa.PrivateKey
}
func (u mockUser) GetEmail() string { return u.email }
func (u mockUser) GetRegistration() *RegistrationResource { return u.regres }
func (u mockUser) GetPrivateKey() crypto.PrivateKey { return u.privatekey }

View file

@ -1,341 +0,0 @@
// Package acme contains all objects related the ACME endpoints.
// https://www.rfc-editor.org/rfc/rfc8555.html
package acme
import (
"encoding/json"
"time"
)
// ACME status values of Account, Order, Authorization and Challenge objects.
// See https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.6 for details.
const (
StatusDeactivated = "deactivated"
StatusExpired = "expired"
StatusInvalid = "invalid"
StatusPending = "pending"
StatusProcessing = "processing"
StatusReady = "ready"
StatusRevoked = "revoked"
StatusUnknown = "unknown"
StatusValid = "valid"
)
// CRL reason codes as defined in RFC 5280.
// https://datatracker.ietf.org/doc/html/rfc5280#section-5.3.1
const (
CRLReasonUnspecified uint = 0
CRLReasonKeyCompromise uint = 1
CRLReasonCACompromise uint = 2
CRLReasonAffiliationChanged uint = 3
CRLReasonSuperseded uint = 4
CRLReasonCessationOfOperation uint = 5
CRLReasonCertificateHold uint = 6
CRLReasonRemoveFromCRL uint = 8
CRLReasonPrivilegeWithdrawn uint = 9
CRLReasonAACompromise uint = 10
)
// 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/
type Directory struct {
NewNonceURL string `json:"newNonce"`
NewAccountURL string `json:"newAccount"`
NewOrderURL string `json:"newOrder"`
NewAuthzURL string `json:"newAuthz"`
RevokeCertURL string `json:"revokeCert"`
KeyChangeURL string `json:"keyChange"`
Meta Meta `json:"meta"`
RenewalInfo string `json:"renewalInfo"`
}
// Meta the ACME meta object (related to Directory).
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.1
type Meta struct {
// termsOfService (optional, string):
// A URL identifying the current terms of service.
TermsOfService string `json:"termsOfService"`
// website (optional, string):
// An HTTP or HTTPS URL locating a website providing more information about the ACME server.
Website string `json:"website"`
// caaIdentities (optional, array of string):
// The hostnames that the ACME server recognizes as referring to itself
// for the purposes of CAA record validation as defined in [RFC6844].
// Each string MUST represent the same sequence of ASCII code points
// that the server will expect to see as the "Issuer Domain Name" in a CAA issue or issuewild property tag.
// This allows clients to determine the correct issuer domain name to use when configuring CAA records.
CaaIdentities []string `json:"caaIdentities"`
// externalAccountRequired (optional, boolean):
// If this field is present and set to "true",
// 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"`
}
// ExtendedAccount a extended Account.
type ExtendedAccount struct {
Account
// Contains the value of the response header `Location`
Location string `json:"-"`
}
// Account the ACME account Object.
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.2
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3
type Account struct {
// status (required, string):
// The status of this account.
// Possible values are: "valid", "deactivated", and "revoked".
// The value "deactivated" should be used to indicate client-initiated deactivation
// whereas "revoked" should be used to indicate server- initiated deactivation. (See Section 7.1.6)
Status string `json:"status,omitempty"`
// contact (optional, array of string):
// An array of URLs that the server can use to contact the client for issues related to this account.
// For example, the server may wish to notify the client about server-initiated revocation or certificate expiration.
// For information on supported URL schemes, see Section 7.3
Contact []string `json:"contact,omitempty"`
// termsOfServiceAgreed (optional, boolean):
// Including this field in a new-account request,
// with a value of true, indicates the client's agreement with the terms of service.
// This field is not updateable by the client.
TermsOfServiceAgreed bool `json:"termsOfServiceAgreed,omitempty"`
// orders (required, string):
// A URL from which a list of orders submitted by this account can be fetched via a POST-as-GET request,
// as described in Section 7.1.2.1.
Orders string `json:"orders,omitempty"`
// onlyReturnExisting (optional, boolean):
// If this field is present with the value "true",
// then the server MUST NOT create a new account if one does not already exist.
// This allows a client to look up an account URL based on an account key (see Section 7.3.1).
OnlyReturnExisting bool `json:"onlyReturnExisting,omitempty"`
// externalAccountBinding (optional, object):
// An optional field for binding the new account with an existing non-ACME account (see Section 7.3.4).
ExternalAccountBinding json.RawMessage `json:"externalAccountBinding,omitempty"`
}
// ExtendedOrder a extended Order.
type ExtendedOrder struct {
Order
// The order URL, contains the value of the response header `Location`
Location string `json:"-"`
}
// Order the ACME order Object.
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.3
type Order struct {
// status (required, string):
// The status of this order.
// Possible values are: "pending", "ready", "processing", "valid", and "invalid".
Status string `json:"status,omitempty"`
// expires (optional, string):
// The timestamp after which the server will consider this order invalid,
// encoded in the format specified in RFC 3339 [RFC3339].
// This field is REQUIRED for objects with "pending" or "valid" in the status field.
Expires string `json:"expires,omitempty"`
// identifiers (required, array of object):
// An array of identifier objects that the order pertains to.
Identifiers []Identifier `json:"identifiers"`
// notBefore (optional, string):
// The requested value of the notBefore field in the certificate,
// in the date format defined in [RFC3339].
NotBefore string `json:"notBefore,omitempty"`
// notAfter (optional, string):
// The requested value of the notAfter field in the certificate,
// in the date format defined in [RFC3339].
NotAfter string `json:"notAfter,omitempty"`
// error (optional, object):
// The error that occurred while processing the order, if any.
// This field is structured as a problem document [RFC7807].
Error *ProblemDetails `json:"error,omitempty"`
// authorizations (required, array of string):
// For pending orders,
// the authorizations that the client needs to complete before the requested certificate can be issued (see Section 7.5),
// including unexpired authorizations that the client has completed in the past for identifiers specified in the order.
// The authorizations required are dictated by server policy
// and there may not be a 1:1 relationship between the order identifiers and the authorizations required.
// For final orders (in the "valid" or "invalid" state), the authorizations that were completed.
// Each entry is a URL from which an authorization can be fetched with a POST-as-GET request.
Authorizations []string `json:"authorizations,omitempty"`
// finalize (required, string):
// A URL that a CSR must be POSTed to once all of the order's authorizations are satisfied to finalize the order.
// The result of a successful finalization will be the population of the certificate URL for the order.
Finalize string `json:"finalize,omitempty"`
// certificate (optional, string):
// A URL for the certificate that has been issued in response to this order
Certificate string `json:"certificate,omitempty"`
}
// Authorization the ACME authorization object.
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.4
type Authorization struct {
// status (required, string):
// The status of this authorization.
// Possible values are: "pending", "valid", "invalid", "deactivated", "expired", and "revoked".
Status string `json:"status"`
// expires (optional, string):
// 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"`
// identifier (required, object):
// The identifier that the account is authorized to represent
Identifier Identifier `json:"identifier,omitempty"`
// challenges (required, array of objects):
// For pending authorizations, the challenges that the client can fulfill in order to prove possession of the identifier.
// For valid authorizations, the challenge that was validated.
// For invalid authorizations, the challenge that was attempted and failed.
// Each array entry is an object with parameters required to validate the challenge.
// A client should attempt to fulfill one of these challenges,
// and a server should consider any one of the challenges sufficient to make the authorization valid.
Challenges []Challenge `json:"challenges,omitempty"`
// wildcard (optional, boolean):
// For authorizations created as a result of a newOrder request containing a DNS identifier
// with a value that contained a wildcard prefix this field MUST be present, and true.
Wildcard bool `json:"wildcard,omitempty"`
}
// 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"
AuthorizationURL string `json:"-"`
}
// Challenge the ACME challenge object.
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.5
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-8
type Challenge struct {
// type (required, string):
// The type of challenge encoded in the object.
Type string `json:"type"`
// url (required, string):
// The URL to which a response can be posted.
URL string `json:"url"`
// status (required, string):
// The status of this challenge. Possible values are: "pending", "processing", "valid", and "invalid".
Status string `json:"status"`
// validated (optional, string):
// 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"`
// error (optional, object):
// Error that occurred while the server was validating the challenge, if any,
// structured as a problem document [RFC7807].
// Multiple errors can be indicated by using subproblems Section 6.7.1.
// A challenge object with an error MUST have status equal to "invalid".
Error *ProblemDetails `json:"error,omitempty"`
// token (required, string):
// A random value that uniquely identifies the challenge.
// This value MUST have at least 128 bits of entropy.
// It MUST NOT contain any characters outside the base64url alphabet,
// and MUST NOT include base64 padding characters ("=").
// See [RFC4086] for additional information on randomness requirements.
// https://www.rfc-editor.org/rfc/rfc8555.html#section-8.3
// https://www.rfc-editor.org/rfc/rfc8555.html#section-8.4
Token string `json:"token"`
// https://www.rfc-editor.org/rfc/rfc8555.html#section-8.1
KeyAuthorization string `json:"keyAuthorization"`
}
// Identifier the ACME identifier object.
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-9.7.7
type Identifier struct {
Type string `json:"type"`
Value string `json:"value"`
}
// CSRMessage Certificate Signing Request.
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4
type CSRMessage struct {
// csr (required, string):
// A CSR encoding the parameters for the certificate being requested [RFC2986].
// The CSR is sent in the base64url-encoded version of the DER format.
// (Note: Because this field uses base64url, and does not include headers, it is different from PEM.).
Csr string `json:"csr"`
}
// RevokeCertMessage a certificate revocation message.
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.6
// - https://www.rfc-editor.org/rfc/rfc5280.html#section-5.3.1
type RevokeCertMessage struct {
// certificate (required, string):
// The certificate to be revoked, in the base64url-encoded version of the DER format.
// (Note: Because this field uses base64url, and does not include headers, it is different from PEM.)
Certificate string `json:"certificate"`
// reason (optional, int):
// One of the revocation reasonCodes defined in Section 5.3.1 of [RFC5280] to be used when generating OCSP responses and CRLs.
// If this field is not set the server SHOULD omit the reasonCode CRL entry extension when generating OCSP responses and CRLs.
// The server MAY disallow a subset of reasonCodes from being used by the user.
// If a request contains a disallowed reasonCode the server MUST reject it with the error type "urn:ietf:params:acme:error:badRevocationReason".
// The problem document detail SHOULD indicate which reasonCodes are allowed.
Reason *uint `json:"reason,omitempty"`
}
// RawCertificate raw data of a certificate.
type RawCertificate struct {
Cert []byte
Issuer []byte
}
// Window is a window of time.
type Window struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
}
// 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/
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.
SuggestedWindow Window `json:"suggestedWindow"`
// ExplanationURL is a optional URL pointing to a page which may explain why the suggested renewal window is what it is.
// For example, it may be a page explaining the CA's dynamic load-balancing strategy,
// or a page documenting which certificates are affected by a mass revocation event.
// Callers SHOULD provide this URL to their operator, if present.
ExplanationURL string `json:"explanationURL"`
}
// RenewalInfoUpdateRequest is the JWS payload for POST requests made to the renewalInfo endpoint.
// - (4.2. Updating Renewal Information) https://datatracker.ietf.org/doc/draft-ietf-acme-ari/
type RenewalInfoUpdateRequest struct {
// CertID is the base64url-encoded [RFC4648] bytes of a DER-encoded CertID ASN.1 sequence [RFC6960] with any trailing '=' characters stripped.
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,
// for instance because it has been renewed and the new certificate is in use, or because it is no longer in use.
// Clients SHOULD NOT send a request where this value is false.
Replaced bool `json:"replaced"`
}

323
acme/crypto.go Normal file
View file

@ -0,0 +1,323 @@
package acme
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"io/ioutil"
"math/big"
"net/http"
"strings"
"time"
"golang.org/x/crypto/ocsp"
)
// KeyType represents the key algo as well as the key size or curve to use.
type KeyType string
type derCertificateBytes []byte
// Constants for all key types we support.
const (
EC256 = KeyType("P256")
EC384 = KeyType("P384")
RSA2048 = KeyType("2048")
RSA4096 = KeyType("4096")
RSA8192 = KeyType("8192")
)
const (
// OCSPGood means that the certificate is valid.
OCSPGood = ocsp.Good
// OCSPRevoked means that the certificate has been deliberately revoked.
OCSPRevoked = ocsp.Revoked
// OCSPUnknown means that the OCSP responder doesn't know about the certificate.
OCSPUnknown = ocsp.Unknown
// OCSPServerFailed means that the OCSP responder failed to process the request.
OCSPServerFailed = ocsp.ServerFailed
)
// GetOCSPForCert takes a PEM encoded cert or cert bundle returning the raw OCSP response,
// the parsed response, and an error, if any. The returned []byte can be passed directly
// into the OCSPStaple property of a tls.Certificate. If the bundle only contains the
// issued certificate, this function will try to get the issuer certificate from the
// IssuingCertificateURL in the certificate. If the []byte and/or ocsp.Response return
// values are nil, the OCSP status may be assumed OCSPUnknown.
func GetOCSPForCert(bundle []byte) ([]byte, *ocsp.Response, error) {
certificates, err := parsePEMBundle(bundle)
if err != nil {
return nil, nil, err
}
// We expect the certificate slice to be ordered downwards the chain.
// SRV CRT -> CA. We need to pull the leaf and issuer certs out of it,
// which should always be the first two certificates. If there's no
// OCSP server listed in the leaf cert, there's nothing to do. And if
// we have only one certificate so far, we need to get the issuer cert.
issuedCert := certificates[0]
if len(issuedCert.OCSPServer) == 0 {
return nil, nil, errors.New("no OCSP server specified in cert")
}
if len(certificates) == 1 {
// TODO: build fallback. If this fails, check the remaining array entries.
if len(issuedCert.IssuingCertificateURL) == 0 {
return nil, nil, errors.New("no issuing certificate URL")
}
resp, err := httpGet(issuedCert.IssuingCertificateURL[0])
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
issuerBytes, err := ioutil.ReadAll(limitReader(resp.Body, 1024*1024))
if err != nil {
return nil, nil, err
}
issuerCert, err := x509.ParseCertificate(issuerBytes)
if err != nil {
return nil, nil, err
}
// Insert it into the slice on position 0
// We want it ordered right SRV CRT -> CA
certificates = append(certificates, issuerCert)
}
issuerCert := certificates[1]
// Finally kick off the OCSP request.
ocspReq, err := ocsp.CreateRequest(issuedCert, issuerCert, nil)
if err != nil {
return nil, nil, err
}
reader := bytes.NewReader(ocspReq)
req, err := httpPost(issuedCert.OCSPServer[0], "application/ocsp-request", reader)
if err != nil {
return nil, nil, err
}
defer req.Body.Close()
ocspResBytes, err := ioutil.ReadAll(limitReader(req.Body, 1024*1024))
ocspRes, err := ocsp.ParseResponse(ocspResBytes, issuerCert)
if err != nil {
return nil, nil, err
}
if ocspRes.Certificate == nil {
err = ocspRes.CheckSignatureFrom(issuerCert)
if err != nil {
return nil, nil, err
}
}
return ocspResBytes, ocspRes, nil
}
func getKeyAuthorization(token string, key interface{}) (string, error) {
var publicKey crypto.PublicKey
switch k := key.(type) {
case *ecdsa.PrivateKey:
publicKey = k.Public()
case *rsa.PrivateKey:
publicKey = k.Public()
}
// Generate the Key Authorization for the challenge
jwk := keyAsJWK(publicKey)
if jwk == nil {
return "", errors.New("Could not generate JWK from key.")
}
thumbBytes, err := jwk.Thumbprint(crypto.SHA256)
if err != nil {
return "", err
}
// unpad the base64URL
keyThumb := base64.URLEncoding.EncodeToString(thumbBytes)
index := strings.Index(keyThumb, "=")
if index != -1 {
keyThumb = keyThumb[:index]
}
return token + "." + keyThumb, nil
}
// 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
for {
certDERBlock, bundle = pem.Decode(bundle)
if certDERBlock == nil {
break
}
if certDERBlock.Type == "CERTIFICATE" {
cert, err := x509.ParseCertificate(certDERBlock.Bytes)
if err != nil {
return nil, err
}
certificates = append(certificates, cert)
}
}
if len(certificates) == 0 {
return nil, errors.New("No certificates were found while parsing the bundle.")
}
return certificates, nil
}
func parsePEMPrivateKey(key []byte) (crypto.PrivateKey, error) {
keyBlock, _ := pem.Decode(key)
switch keyBlock.Type {
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
case "EC PRIVATE KEY":
return x509.ParseECPrivateKey(keyBlock.Bytes)
default:
return nil, errors.New("Unknown PEM header value")
}
}
func generatePrivateKey(keyType KeyType) (crypto.PrivateKey, error) {
switch keyType {
case EC256:
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
case EC384:
return ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
case RSA2048:
return rsa.GenerateKey(rand.Reader, 2048)
case RSA4096:
return rsa.GenerateKey(rand.Reader, 4096)
case RSA8192:
return rsa.GenerateKey(rand.Reader, 8192)
}
return nil, fmt.Errorf("Invalid KeyType: %s", keyType)
}
func generateCsr(privateKey crypto.PrivateKey, domain string, san []string) ([]byte, error) {
template := x509.CertificateRequest{
Subject: pkix.Name{
CommonName: domain,
},
}
if len(san) > 0 {
template.DNSNames = san
}
return x509.CreateCertificateRequest(rand.Reader, &template, privateKey)
}
func pemEncode(data interface{}) []byte {
var pemBlock *pem.Block
switch key := data.(type) {
case *ecdsa.PrivateKey:
keyBytes, _ := x509.MarshalECPrivateKey(key)
pemBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}
case *rsa.PrivateKey:
pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
break
case derCertificateBytes:
pemBlock = &pem.Block{Type: "CERTIFICATE", Bytes: []byte(data.(derCertificateBytes))}
}
return pem.EncodeToMemory(pemBlock)
}
func pemDecode(data []byte) (*pem.Block, error) {
pemBlock, _ := pem.Decode(data)
if pemBlock == nil {
return nil, fmt.Errorf("Pem decode did not yield a valid block. Is the certificate in the right format?")
}
return pemBlock, nil
}
func pemDecodeTox509(pem []byte) (*x509.Certificate, error) {
pemBlock, err := pemDecode(pem)
if pemBlock == nil {
return nil, err
}
return x509.ParseCertificate(pemBlock.Bytes)
}
// GetPEMCertExpiration returns the "NotAfter" date of a PEM encoded certificate.
// The certificate has to be PEM encoded. Any other encodings like DER will fail.
func GetPEMCertExpiration(cert []byte) (time.Time, error) {
pemBlock, err := pemDecode(cert)
if pemBlock == nil {
return time.Time{}, err
}
return getCertExpiration(pemBlock.Bytes)
}
// getCertExpiration returns the "NotAfter" date of a DER encoded certificate.
func getCertExpiration(cert []byte) (time.Time, error) {
pCert, err := x509.ParseCertificate(cert)
if err != nil {
return time.Time{}, err
}
return pCert.NotAfter, nil
}
func generatePemCert(privKey *rsa.PrivateKey, domain string) ([]byte, error) {
derBytes, err := generateDerCert(privKey, time.Time{}, domain)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}), nil
}
func generateDerCert(privKey *rsa.PrivateKey, expiration time.Time, domain string) ([]byte, error) {
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, err
}
if expiration.IsZero() {
expiration = time.Now().Add(365)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: "ACME Challenge TEMP",
},
NotBefore: time.Now(),
NotAfter: expiration,
KeyUsage: x509.KeyUsageKeyEncipherment,
BasicConstraintsValid: true,
DNSNames: []string{domain},
}
return x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
}
func limitReader(rd io.ReadCloser, numBytes int64) io.ReadCloser {
return http.MaxBytesReader(nil, rd, numBytes)
}

93
acme/crypto_test.go Normal file
View file

@ -0,0 +1,93 @@
package acme
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"testing"
"time"
)
func TestGeneratePrivateKey(t *testing.T) {
key, err := generatePrivateKey(RSA2048)
if err != nil {
t.Error("Error generating private key:", err)
}
if key == nil {
t.Error("Expected key to not be nil, but it was")
}
}
func TestGenerateCSR(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 512)
if err != nil {
t.Fatal("Error generating private key:", err)
}
csr, err := generateCsr(key, "fizz.buzz", nil)
if err != nil {
t.Error("Error generating CSR:", err)
}
if csr == nil || len(csr) == 0 {
t.Error("Expected CSR with data, but it was nil or length 0")
}
}
func TestPEMEncode(t *testing.T) {
buf := bytes.NewBufferString("TestingRSAIsSoMuchFun")
reader := MockRandReader{b: buf}
key, err := rsa.GenerateKey(reader, 32)
if err != nil {
t.Fatal("Error generating private key:", err)
}
data := pemEncode(key)
if data == nil {
t.Fatal("Expected result to not be nil, but it was")
}
if len(data) != 127 {
t.Errorf("Expected PEM encoding to be length 127, but it was %d", len(data))
}
}
func TestPEMCertExpiration(t *testing.T) {
privKey, err := generatePrivateKey(RSA2048)
if err != nil {
t.Fatal("Error generating private key:", err)
}
expiration := time.Now().Add(365)
expiration = expiration.Round(time.Second)
certBytes, err := generateDerCert(privKey.(*rsa.PrivateKey), expiration, "test.com")
if err != nil {
t.Fatal("Error generating cert:", err)
}
buf := bytes.NewBufferString("TestingRSAIsSoMuchFun")
// Some random string should return an error.
if ctime, err := GetPEMCertExpiration(buf.Bytes()); err == nil {
t.Errorf("Expected getCertExpiration to return an error for garbage string but returned %v", ctime)
}
// A DER encoded certificate should return an error.
if _, err := GetPEMCertExpiration(certBytes); err == nil {
t.Errorf("Expected getCertExpiration to return an error for DER certificates but returned none.")
}
// A PEM encoded certificate should work ok.
pemCert := pemEncode(derCertificateBytes(certBytes))
if ctime, err := GetPEMCertExpiration(pemCert); err != nil || !ctime.Equal(expiration.UTC()) {
t.Errorf("Expected getCertExpiration to return %v but returned %v. Error: %v", expiration, ctime, err)
}
}
type MockRandReader struct {
b *bytes.Buffer
}
func (r MockRandReader) Read(p []byte) (int, error) {
return r.b.Read(p)
}

263
acme/dns_challenge.go Normal file
View file

@ -0,0 +1,263 @@
package acme
import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"log"
"net"
"strings"
"time"
"github.com/miekg/dns"
"golang.org/x/net/publicsuffix"
)
type preCheckDNSFunc func(fqdn, value string) (bool, error)
var (
preCheckDNS preCheckDNSFunc = checkDNSPropagation
fqdnToZone = map[string]string{}
)
var RecursiveNameserver = "google-public-dns-a.google.com:53"
// DNS01Record returns a DNS record which will fulfill the `dns-01` challenge
func DNS01Record(domain, keyAuth string) (fqdn string, value string, ttl int) {
keyAuthShaBytes := sha256.Sum256([]byte(keyAuth))
// base64URL encoding without padding
keyAuthSha := base64.URLEncoding.EncodeToString(keyAuthShaBytes[:sha256.Size])
value = strings.TrimRight(keyAuthSha, "=")
ttl = 120
fqdn = fmt.Sprintf("_acme-challenge.%s.", domain)
return
}
// dnsChallenge implements the dns-01 challenge according to ACME 7.5
type dnsChallenge struct {
jws *jws
validate validateFunc
provider ChallengeProvider
}
func (s *dnsChallenge) Solve(chlng challenge, domain string) error {
logf("[INFO][%s] acme: Trying to solve DNS-01", domain)
if s.provider == nil {
return errors.New("No DNS Provider configured")
}
// Generate the Key Authorization for the challenge
keyAuth, err := getKeyAuthorization(chlng.Token, s.jws.privKey)
if err != nil {
return err
}
err = s.provider.Present(domain, chlng.Token, keyAuth)
if err != nil {
return fmt.Errorf("Error presenting token %s", err)
}
defer func() {
err := s.provider.CleanUp(domain, chlng.Token, keyAuth)
if err != nil {
log.Printf("Error cleaning up %s %v ", domain, err)
}
}()
fqdn, value, _ := DNS01Record(domain, keyAuth)
logf("[INFO][%s] Checking DNS record propagation...", domain)
var timeout, interval time.Duration
switch provider := s.provider.(type) {
case ChallengeProviderTimeout:
timeout, interval = provider.Timeout()
default:
timeout, interval = 60*time.Second, 2*time.Second
}
err = WaitFor(timeout, interval, func() (bool, error) {
return preCheckDNS(fqdn, value)
})
if err != nil {
return err
}
return s.validate(s.jws, domain, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth})
}
// checkDNSPropagation checks if the expected TXT record has been propagated to all authoritative nameservers.
func checkDNSPropagation(fqdn, value string) (bool, error) {
// Initial attempt to resolve at the recursive NS
r, err := dnsQuery(fqdn, dns.TypeTXT, RecursiveNameserver, true)
if err != nil {
return false, err
}
if r.Rcode == dns.RcodeSuccess {
// If we see a CNAME here then use the alias
for _, rr := range r.Answer {
if cn, ok := rr.(*dns.CNAME); ok {
if cn.Hdr.Name == fqdn {
fqdn = cn.Target
break
}
}
}
}
authoritativeNss, err := lookupNameservers(fqdn)
if err != nil {
return false, err
}
return checkAuthoritativeNss(fqdn, value, authoritativeNss)
}
// checkAuthoritativeNss queries each of the given nameservers for the expected TXT record.
func checkAuthoritativeNss(fqdn, value string, nameservers []string) (bool, error) {
for _, ns := range nameservers {
r, err := dnsQuery(fqdn, dns.TypeTXT, net.JoinHostPort(ns, "53"), false)
if err != nil {
return false, err
}
if r.Rcode != dns.RcodeSuccess {
return false, fmt.Errorf("NS %s returned %s for %s", ns, dns.RcodeToString[r.Rcode], fqdn)
}
var found bool
for _, rr := range r.Answer {
if txt, ok := rr.(*dns.TXT); ok {
if strings.Join(txt.Txt, "") == value {
found = true
break
}
}
}
if !found {
return false, fmt.Errorf("NS %s did not return the expected TXT record", ns)
}
}
return true, nil
}
// dnsQuery sends a DNS query to the given nameserver.
// The nameserver should include a port, to facilitate testing where we talk to a mock dns server.
func dnsQuery(fqdn string, rtype uint16, nameserver string, recursive bool) (in *dns.Msg, err error) {
m := new(dns.Msg)
m.SetQuestion(fqdn, rtype)
m.SetEdns0(4096, false)
if !recursive {
m.RecursionDesired = false
}
in, err = dns.Exchange(m, nameserver)
if err == dns.ErrTruncated {
tcp := &dns.Client{Net: "tcp"}
in, _, err = tcp.Exchange(m, nameserver)
}
return
}
// lookupNameservers returns the authoritative nameservers for the given fqdn.
func lookupNameservers(fqdn string) ([]string, error) {
var authoritativeNss []string
zone, err := FindZoneByFqdn(fqdn, RecursiveNameserver)
if err != nil {
return nil, err
}
r, err := dnsQuery(zone, dns.TypeNS, RecursiveNameserver, true)
if err != nil {
return nil, err
}
for _, rr := range r.Answer {
if ns, ok := rr.(*dns.NS); ok {
authoritativeNss = append(authoritativeNss, strings.ToLower(ns.Ns))
}
}
if len(authoritativeNss) > 0 {
return authoritativeNss, nil
}
return nil, fmt.Errorf("Could not determine authoritative nameservers")
}
// FindZoneByFqdn determines the zone of the given fqdn
func FindZoneByFqdn(fqdn, nameserver string) (string, error) {
// Do we have it cached?
if zone, ok := fqdnToZone[fqdn]; ok {
return zone, nil
}
// Query the authoritative nameserver for a hopefully non-existing SOA record,
// in the authority section of the reply it will have the SOA of the
// containing zone. rfc2308 has this to say on the subject:
// Name servers authoritative for a zone MUST include the SOA record of
// the zone in the authority section of the response when reporting an
// NXDOMAIN or indicating that no data (NODATA) of the requested type exists
in, err := dnsQuery(fqdn, dns.TypeSOA, nameserver, true)
if err != nil {
return "", err
}
if in.Rcode != dns.RcodeNameError {
if in.Rcode != dns.RcodeSuccess {
return "", fmt.Errorf("NS %s returned %s for %s", nameserver, dns.RcodeToString[in.Rcode], fqdn)
}
// We have a success, so one of the answers has to be a SOA RR
for _, ans := range in.Answer {
if soa, ok := ans.(*dns.SOA); ok {
return checkIfTLD(fqdn, soa)
}
}
// Or it is NODATA, fall through to NXDOMAIN
}
// Search the authority section for our precious SOA RR
for _, ns := range in.Ns {
if soa, ok := ns.(*dns.SOA); ok {
return checkIfTLD(fqdn, soa)
}
}
return "", fmt.Errorf("NS %s did not return the expected SOA record in the authority section", nameserver)
}
func checkIfTLD(fqdn string, soa *dns.SOA) (string, error) {
zone := soa.Hdr.Name
// If we ended up on one of the TLDs, it means the domain did not exist.
publicsuffix, _ := publicsuffix.PublicSuffix(UnFqdn(zone))
if publicsuffix == UnFqdn(zone) {
return "", fmt.Errorf("Could not determine zone authoritatively")
}
fqdnToZone[fqdn] = zone
return zone, nil
}
// ClearFqdnCache clears the cache of fqdn to zone mappings. Primarily used in testing.
func ClearFqdnCache() {
fqdnToZone = map[string]string{}
}
// ToFqdn converts the name into a fqdn appending a trailing dot.
func ToFqdn(name string) string {
n := len(name)
if n == 0 || name[n-1] == '.' {
return name
}
return name + "."
}
// UnFqdn converts the fqdn into a name removing the trailing dot.
func UnFqdn(name string) string {
n := len(name)
if n != 0 && name[n-1] == '.' {
return name[:n-1]
}
return name
}

View file

@ -0,0 +1,53 @@
package acme
import (
"bufio"
"fmt"
"os"
)
const (
dnsTemplate = "%s %d IN TXT \"%s\""
)
// DNSProviderManual is an implementation of the ChallengeProvider interface
type DNSProviderManual struct{}
// NewDNSProviderManual returns a DNSProviderManual instance.
func NewDNSProviderManual() (*DNSProviderManual, error) {
return &DNSProviderManual{}, nil
}
// Present prints instructions for manually creating the TXT record
func (*DNSProviderManual) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := DNS01Record(domain, keyAuth)
dnsRecord := fmt.Sprintf(dnsTemplate, fqdn, ttl, value)
authZone, err := FindZoneByFqdn(fqdn, RecursiveNameserver)
if err != nil {
return err
}
logf("[INFO] acme: Please create the following TXT record in your %s zone:", authZone)
logf("[INFO] acme: %s", dnsRecord)
logf("[INFO] acme: Press 'Enter' when you are done")
reader := bufio.NewReader(os.Stdin)
_, _ = reader.ReadString('\n')
return nil
}
// CleanUp prints instructions for manually removing the TXT record
func (*DNSProviderManual) CleanUp(domain, token, keyAuth string) error {
fqdn, _, ttl := DNS01Record(domain, keyAuth)
dnsRecord := fmt.Sprintf(dnsTemplate, fqdn, ttl, "...")
authZone, err := FindZoneByFqdn(fqdn, RecursiveNameserver)
if err != nil {
return err
}
logf("[INFO] acme: You can now remove this TXT record from your %s zone:", authZone)
logf("[INFO] acme: %s", dnsRecord)
return nil
}

165
acme/dns_challenge_test.go Normal file
View file

@ -0,0 +1,165 @@
package acme
import (
"bufio"
"crypto/rand"
"crypto/rsa"
"net/http"
"net/http/httptest"
"os"
"reflect"
"sort"
"strings"
"testing"
"time"
)
var lookupNameserversTestsOK = []struct {
fqdn string
nss []string
}{
{"books.google.com.ng.",
[]string{"ns1.google.com.", "ns2.google.com.", "ns3.google.com.", "ns4.google.com."},
},
{"www.google.com.",
[]string{"ns1.google.com.", "ns2.google.com.", "ns3.google.com.", "ns4.google.com."},
},
{"physics.georgetown.edu.",
[]string{"ns1.georgetown.edu.", "ns2.georgetown.edu.", "ns3.georgetown.edu."},
},
}
var lookupNameserversTestsErr = []struct {
fqdn string
error string
}{
// invalid tld
{"_null.n0n0.",
"Could not determine zone authoritatively",
},
// invalid domain
{"_null.com.",
"Could not determine zone authoritatively",
},
// invalid domain
{"in-valid.co.uk.",
"Could not determine zone authoritatively",
},
}
var checkAuthoritativeNssTests = []struct {
fqdn, value string
ns []string
ok bool
}{
// TXT RR w/ expected value
{"8.8.8.8.asn.routeviews.org.", "151698.8.8.024", []string{"asnums.routeviews.org."},
true,
},
// No TXT RR
{"ns1.google.com.", "", []string{"ns2.google.com."},
false,
},
}
var checkAuthoritativeNssTestsErr = []struct {
fqdn, value string
ns []string
error string
}{
// TXT RR /w unexpected value
{"8.8.8.8.asn.routeviews.org.", "fe01=", []string{"asnums.routeviews.org."},
"did not return the expected TXT record",
},
// No TXT RR
{"ns1.google.com.", "fe01=", []string{"ns2.google.com."},
"did not return the expected TXT record",
},
}
func TestDNSValidServerResponse(t *testing.T) {
preCheckDNS = func(fqdn, value string) (bool, error) {
return true, nil
}
privKey, _ := rsa.GenerateKey(rand.Reader, 512)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Replay-Nonce", "12345")
w.Write([]byte("{\"type\":\"dns01\",\"status\":\"valid\",\"uri\":\"http://some.url\",\"token\":\"http8\"}"))
}))
manualProvider, _ := NewDNSProviderManual()
jws := &jws{privKey: privKey, directoryURL: ts.URL}
solver := &dnsChallenge{jws: jws, validate: validate, provider: manualProvider}
clientChallenge := challenge{Type: "dns01", Status: "pending", URI: ts.URL, Token: "http8"}
go func() {
time.Sleep(time.Second * 2)
f := bufio.NewWriter(os.Stdout)
defer f.Flush()
f.WriteString("\n")
}()
if err := solver.Solve(clientChallenge, "example.com"); err != nil {
t.Errorf("VALID: Expected Solve to return no error but the error was -> %v", err)
}
}
func TestPreCheckDNS(t *testing.T) {
ok, err := preCheckDNS("acme-staging.api.letsencrypt.org", "fe01=")
if err != nil || !ok {
t.Errorf("preCheckDNS failed for acme-staging.api.letsencrypt.org")
}
}
func TestLookupNameserversOK(t *testing.T) {
for _, tt := range lookupNameserversTestsOK {
nss, err := lookupNameservers(tt.fqdn)
if err != nil {
t.Fatalf("#%s: got %q; want nil", tt.fqdn, err)
}
sort.Strings(nss)
sort.Strings(tt.nss)
if !reflect.DeepEqual(nss, tt.nss) {
t.Errorf("#%s: got %v; want %v", tt.fqdn, nss, tt.nss)
}
}
}
func TestLookupNameserversErr(t *testing.T) {
for _, tt := range lookupNameserversTestsErr {
_, err := lookupNameservers(tt.fqdn)
if err == nil {
t.Fatalf("#%s: expected %q (error); got <nil>", tt.fqdn, tt.error)
}
if !strings.Contains(err.Error(), tt.error) {
t.Errorf("#%s: expected %q (error); got %q", tt.fqdn, tt.error, err)
continue
}
}
}
func TestCheckAuthoritativeNss(t *testing.T) {
for _, tt := range checkAuthoritativeNssTests {
ok, _ := checkAuthoritativeNss(tt.fqdn, tt.value, tt.ns)
if ok != tt.ok {
t.Errorf("%s: got %t; want %t", tt.fqdn, ok, tt.ok)
}
}
}
func TestCheckAuthoritativeNssErr(t *testing.T) {
for _, tt := range checkAuthoritativeNssTestsErr {
_, err := checkAuthoritativeNss(tt.fqdn, tt.value, tt.ns)
if err == nil {
t.Fatalf("#%s: expected %q (error); got <nil>", tt.fqdn, tt.error)
}
if !strings.Contains(err.Error(), tt.error) {
t.Errorf("#%s: expected %q (error); got %q", tt.fqdn, tt.error, err)
continue
}
}
}

77
acme/error.go Normal file
View file

@ -0,0 +1,77 @@
package acme
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
const (
tosAgreementError = "Must agree to subscriber agreement before any further actions"
)
// RemoteError is the base type for all errors specific to the ACME protocol.
type RemoteError struct {
StatusCode int `json:"status,omitempty"`
Type string `json:"type"`
Detail string `json:"detail"`
}
func (e RemoteError) Error() string {
return fmt.Sprintf("acme: Error %d - %s - %s", e.StatusCode, e.Type, e.Detail)
}
// TOSError represents the error which is returned if the user needs to
// accept the TOS.
// TODO: include the new TOS url if we can somehow obtain it.
type TOSError struct {
RemoteError
}
type domainError struct {
Domain string
Error error
}
type challengeError struct {
RemoteError
records []validationRecord
}
func (c challengeError) Error() string {
var errStr string
for _, validation := range c.records {
errStr = errStr + fmt.Sprintf("\tValidation for %s:%s\n\tResolved to:\n\t\t%s\n\tUsed: %s\n\n",
validation.Hostname, validation.Port, strings.Join(validation.ResolvedAddresses, "\n\t\t"), validation.UsedAddress)
}
return fmt.Sprintf("%s\nError Detail:\n%s", c.RemoteError.Error(), errStr)
}
func handleHTTPError(resp *http.Response) error {
var errorDetail RemoteError
// try to decode the content as JSON
if resp.Header.Get("Content-Type") == "application/json" {
decoder := json.NewDecoder(resp.Body)
err := decoder.Decode(&errorDetail)
if err != nil {
return err
}
}
errorDetail.StatusCode = resp.StatusCode
// Check for errors we handle specifically
if errorDetail.StatusCode == http.StatusForbidden && errorDetail.Detail == tosAgreementError {
return TOSError{errorDetail}
}
return errorDetail
}
func handleChallengeError(chlng challenge) error {
return challengeError{chlng.Error, chlng.ValidationRecords}
}

View file

@ -1,58 +0,0 @@
package acme
import (
"fmt"
)
// Errors types.
const (
errNS = "urn:ietf:params:acme:error:"
BadNonceErr = errNS + "badNonce"
)
// ProblemDetails the problem details object.
// - https://www.rfc-editor.org/rfc/rfc7807.html#section-3.1
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3.3
type ProblemDetails struct {
Type string `json:"type,omitempty"`
Detail string `json:"detail,omitempty"`
HTTPStatus int `json:"status,omitempty"`
Instance string `json:"instance,omitempty"`
SubProblems []SubProblem `json:"subproblems,omitempty"`
// additional values to have a better error message (Not defined by the RFC)
Method string `json:"method,omitempty"`
URL string `json:"url,omitempty"`
}
// 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
}
// NonceError represents the error which is returned
// if the nonce sent by the client was not accepted by the server.
type NonceError struct {
*ProblemDetails
}

119
acme/http.go Normal file
View file

@ -0,0 +1,119 @@
package acme
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"runtime"
"strings"
"time"
)
// UserAgent (if non-empty) will be tacked onto the User-Agent string in requests.
var UserAgent string
// defaultClient is an HTTP client with a reasonable timeout value.
var defaultClient = http.Client{Timeout: 10 * time.Second}
const (
// defaultGoUserAgent is the Go HTTP package user agent string. Too
// bad it isn't exported. If it changes, we should update it here, too.
defaultGoUserAgent = "Go-http-client/1.1"
// ourUserAgent is the User-Agent of this underlying library package.
ourUserAgent = "xenolf-acme"
)
// httpHead performs a HEAD request with a proper User-Agent string.
// The response body (resp.Body) is already closed when this function returns.
func httpHead(url string) (resp *http.Response, err error) {
req, err := http.NewRequest("HEAD", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent())
resp, err = defaultClient.Do(req)
if err != nil {
return resp, err
}
resp.Body.Close()
return resp, err
}
// httpPost performs a POST request with a proper User-Agent string.
// Callers should close resp.Body when done reading from it.
func httpPost(url string, bodyType string, body io.Reader) (resp *http.Response, err error) {
req, err := http.NewRequest("POST", url, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", bodyType)
req.Header.Set("User-Agent", userAgent())
return defaultClient.Do(req)
}
// httpGet performs a GET request with a proper User-Agent string.
// Callers should close resp.Body when done reading from it.
func httpGet(url string) (resp *http.Response, err error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent())
return defaultClient.Do(req)
}
// getJSON performs an HTTP GET request and parses the response body
// as JSON, into the provided respBody object.
func getJSON(uri string, respBody interface{}) (http.Header, error) {
resp, err := httpGet(uri)
if err != nil {
return nil, fmt.Errorf("failed to get %q: %v", uri, err)
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
return resp.Header, handleHTTPError(resp)
}
return resp.Header, json.NewDecoder(resp.Body).Decode(respBody)
}
// postJSON performs an HTTP POST request and parses the response body
// as JSON, into the provided respBody object.
func postJSON(j *jws, uri string, reqBody, respBody interface{}) (http.Header, error) {
jsonBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, errors.New("Failed to marshal network message...")
}
logf("[DEBUG] Attempting to post %s to %s", string(jsonBytes), uri)
resp, err := j.post(uri, jsonBytes)
if err != nil {
return nil, fmt.Errorf("Failed to post JWS message. -> %v", err)
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
return resp.Header, handleHTTPError(resp)
}
if respBody == nil {
return resp.Header, nil
}
return resp.Header, json.NewDecoder(resp.Body).Decode(respBody)
}
// userAgent builds and returns the User-Agent string to use in requests.
func userAgent() string {
ua := fmt.Sprintf("%s (%s; %s) %s %s", defaultGoUserAgent, runtime.GOOS, runtime.GOARCH, ourUserAgent, UserAgent)
return strings.TrimSpace(ua)
}

41
acme/http_challenge.go Normal file
View file

@ -0,0 +1,41 @@
package acme
import (
"fmt"
"log"
)
type httpChallenge struct {
jws *jws
validate validateFunc
provider ChallengeProvider
}
// HTTP01ChallengePath returns the URL path for the `http-01` challenge
func HTTP01ChallengePath(token string) string {
return "/.well-known/acme-challenge/" + token
}
func (s *httpChallenge) Solve(chlng challenge, domain string) error {
logf("[INFO][%s] acme: Trying to solve HTTP-01", domain)
// Generate the Key Authorization for the challenge
keyAuth, err := getKeyAuthorization(chlng.Token, s.jws.privKey)
if err != nil {
return err
}
err = s.provider.Present(domain, chlng.Token, keyAuth)
if err != nil {
return fmt.Errorf("[%s] error presenting token: %v", domain, err)
}
defer func() {
err := s.provider.CleanUp(domain, chlng.Token, keyAuth)
if err != nil {
log.Printf("[%s] error cleaning up: %v", domain, err)
}
}()
return s.validate(s.jws, domain, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth})
}

View file

@ -0,0 +1,79 @@
package acme
import (
"fmt"
"net"
"net/http"
"strings"
)
// HTTPProviderServer implements ChallengeProvider for `http-01` challenge
// It may be instantiated without using the NewHTTPProviderServer function if
// you want only to use the default values.
type HTTPProviderServer struct {
iface string
port string
done chan bool
listener net.Listener
}
// NewHTTPProviderServer creates a new HTTPProviderServer on the selected interface and port.
// Setting iface and / or port to an empty string will make the server fall back to
// the "any" interface and port 80 respectively.
func NewHTTPProviderServer(iface, port string) *HTTPProviderServer {
return &HTTPProviderServer{iface: iface, port: port}
}
// Present starts a web server and makes the token available at `HTTP01ChallengePath(token)` for web requests.
func (s *HTTPProviderServer) Present(domain, token, keyAuth string) error {
if s.port == "" {
s.port = "80"
}
var err error
s.listener, err = net.Listen("tcp", net.JoinHostPort(s.iface, s.port))
if err != nil {
return fmt.Errorf("Could not start HTTP server for challenge -> %v", err)
}
s.done = make(chan bool)
go s.serve(domain, token, keyAuth)
return nil
}
// CleanUp closes the HTTP server and removes the token from `HTTP01ChallengePath(token)`
func (s *HTTPProviderServer) CleanUp(domain, token, keyAuth string) error {
if s.listener == nil {
return nil
}
s.listener.Close()
<-s.done
return nil
}
func (s *HTTPProviderServer) serve(domain, token, keyAuth string) {
path := HTTP01ChallengePath(token)
// The handler validates the HOST header and request type.
// For validation it then writes the token the server returned with the challenge
mux := http.NewServeMux()
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.Host, domain) && r.Method == "GET" {
w.Header().Add("Content-Type", "text/plain")
w.Write([]byte(keyAuth))
logf("[INFO][%s] Served key authentication", domain)
} else {
logf("[INFO] Received request for domain %s with method %s", r.Host, r.Method)
w.Write([]byte("TEST"))
}
})
httpServer := &http.Server{
Handler: mux,
}
// Once httpServer is shut down we don't want any lingering
// connections, so disable KeepAlives.
httpServer.SetKeepAlivesEnabled(false)
httpServer.Serve(s.listener)
s.done <- true
}

View file

@ -0,0 +1,57 @@
package acme
import (
"crypto/rand"
"crypto/rsa"
"io/ioutil"
"strings"
"testing"
)
func TestHTTPChallenge(t *testing.T) {
privKey, _ := rsa.GenerateKey(rand.Reader, 512)
j := &jws{privKey: privKey}
clientChallenge := challenge{Type: HTTP01, Token: "http1"}
mockValidate := func(_ *jws, _, _ string, chlng challenge) error {
uri := "http://localhost:23457/.well-known/acme-challenge/" + chlng.Token
resp, err := httpGet(uri)
if err != nil {
return err
}
defer resp.Body.Close()
if want := "text/plain"; resp.Header.Get("Content-Type") != want {
t.Errorf("Get(%q) Content-Type: got %q, want %q", uri, resp.Header.Get("Content-Type"), want)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
bodyStr := string(body)
if bodyStr != chlng.KeyAuthorization {
t.Errorf("Get(%q) Body: got %q, want %q", uri, bodyStr, chlng.KeyAuthorization)
}
return nil
}
solver := &httpChallenge{jws: j, validate: mockValidate, provider: &HTTPProviderServer{port: "23457"}}
if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil {
t.Errorf("Solve error: got %v, want nil", err)
}
}
func TestHTTPChallengeInvalidPort(t *testing.T) {
privKey, _ := rsa.GenerateKey(rand.Reader, 128)
j := &jws{privKey: privKey}
clientChallenge := challenge{Type: HTTP01, Token: "http2"}
solver := &httpChallenge{jws: j, validate: stubValidate, provider: &HTTPProviderServer{port: "123456"}}
if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil {
t.Errorf("Solve error: got %v, want error", err)
} else if want := "invalid port 123456"; !strings.HasSuffix(err.Error(), want) {
t.Errorf("Solve error: got %q, want suffix %q", err.Error(), want)
}
}

100
acme/http_test.go Normal file
View file

@ -0,0 +1,100 @@
package acme
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHTTPHeadUserAgent(t *testing.T) {
var ua, method string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ua = r.Header.Get("User-Agent")
method = r.Method
}))
defer ts.Close()
_, err := httpHead(ts.URL)
if err != nil {
t.Fatal(err)
}
if method != "HEAD" {
t.Errorf("Expected method to be HEAD, got %s", method)
}
if !strings.Contains(ua, ourUserAgent) {
t.Errorf("Expected User-Agent to contain '%s', got: '%s'", ourUserAgent, ua)
}
}
func TestHTTPGetUserAgent(t *testing.T) {
var ua, method string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ua = r.Header.Get("User-Agent")
method = r.Method
}))
defer ts.Close()
res, err := httpGet(ts.URL)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if method != "GET" {
t.Errorf("Expected method to be GET, got %s", method)
}
if !strings.Contains(ua, ourUserAgent) {
t.Errorf("Expected User-Agent to contain '%s', got: '%s'", ourUserAgent, ua)
}
}
func TestHTTPPostUserAgent(t *testing.T) {
var ua, method string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ua = r.Header.Get("User-Agent")
method = r.Method
}))
defer ts.Close()
res, err := httpPost(ts.URL, "text/plain", strings.NewReader("falalalala"))
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if method != "POST" {
t.Errorf("Expected method to be POST, got %s", method)
}
if !strings.Contains(ua, ourUserAgent) {
t.Errorf("Expected User-Agent to contain '%s', got: '%s'", ourUserAgent, ua)
}
}
func TestUserAgent(t *testing.T) {
ua := userAgent()
if !strings.Contains(ua, defaultGoUserAgent) {
t.Errorf("Expected UA to contain %s, got '%s'", defaultGoUserAgent, ua)
}
if !strings.Contains(ua, ourUserAgent) {
t.Errorf("Expected UA to contain %s, got '%s'", ourUserAgent, ua)
}
if strings.HasSuffix(ua, " ") {
t.Errorf("UA should not have trailing spaces; got '%s'", ua)
}
// customize the UA by appending a value
UserAgent = "MyApp/1.2.3"
ua = userAgent()
if !strings.Contains(ua, defaultGoUserAgent) {
t.Errorf("Expected UA to contain %s, got '%s'", defaultGoUserAgent, ua)
}
if !strings.Contains(ua, ourUserAgent) {
t.Errorf("Expected UA to contain %s, got '%s'", ourUserAgent, ua)
}
if !strings.Contains(ua, UserAgent) {
t.Errorf("Expected custom UA to contain %s, got '%s'", UserAgent, ua)
}
}

109
acme/jws.go Normal file
View file

@ -0,0 +1,109 @@
package acme
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"fmt"
"net/http"
"gopkg.in/square/go-jose.v1"
)
type jws struct {
directoryURL string
privKey crypto.PrivateKey
nonces []string
}
func keyAsJWK(key interface{}) *jose.JsonWebKey {
switch k := key.(type) {
case *ecdsa.PublicKey:
return &jose.JsonWebKey{Key: k, Algorithm: "EC"}
case *rsa.PublicKey:
return &jose.JsonWebKey{Key: k, Algorithm: "RSA"}
default:
return nil
}
}
// Posts a JWS signed message to the specified URL
func (j *jws) post(url string, content []byte) (*http.Response, error) {
signedContent, err := j.signContent(content)
if err != nil {
return nil, err
}
resp, err := httpPost(url, "application/jose+json", bytes.NewBuffer([]byte(signedContent.FullSerialize())))
if err != nil {
return nil, err
}
j.getNonceFromResponse(resp)
return resp, err
}
func (j *jws) signContent(content []byte) (*jose.JsonWebSignature, error) {
var alg jose.SignatureAlgorithm
switch k := j.privKey.(type) {
case *rsa.PrivateKey:
alg = jose.RS256
case *ecdsa.PrivateKey:
if k.Curve == elliptic.P256() {
alg = jose.ES256
} else if k.Curve == elliptic.P384() {
alg = jose.ES384
}
}
signer, err := jose.NewSigner(alg, j.privKey)
if err != nil {
return nil, err
}
signer.SetNonceSource(j)
signed, err := signer.Sign(content)
if err != nil {
return nil, err
}
return signed, nil
}
func (j *jws) getNonceFromResponse(resp *http.Response) error {
nonce := resp.Header.Get("Replay-Nonce")
if nonce == "" {
return fmt.Errorf("Server did not respond with a proper nonce header.")
}
j.nonces = append(j.nonces, nonce)
return nil
}
func (j *jws) getNonce() error {
resp, err := httpHead(j.directoryURL)
if err != nil {
return err
}
return j.getNonceFromResponse(resp)
}
func (j *jws) Nonce() (string, error) {
nonce := ""
if len(j.nonces) == 0 {
err := j.getNonce()
if err != nil {
return nonce, err
}
}
if len(j.nonces) == 0 {
return "", fmt.Errorf("Can't get nonce")
}
nonce, j.nonces = j.nonces[len(j.nonces)-1], j.nonces[:len(j.nonces)-1]
return nonce, nil
}

115
acme/messages.go Normal file
View file

@ -0,0 +1,115 @@
package acme
import (
"time"
"gopkg.in/square/go-jose.v1"
)
type directory struct {
NewAuthzURL string `json:"new-authz"`
NewCertURL string `json:"new-cert"`
NewRegURL string `json:"new-reg"`
RevokeCertURL string `json:"revoke-cert"`
}
type recoveryKeyMessage struct {
Length int `json:"length,omitempty"`
Client jose.JsonWebKey `json:"client,omitempty"`
Server jose.JsonWebKey `json:"client,omitempty"`
}
type registrationMessage struct {
Resource string `json:"resource"`
Contact []string `json:"contact"`
// RecoveryKey recoveryKeyMessage `json:"recoveryKey,omitempty"`
}
// Registration is returned by the ACME server after the registration
// The client implementation should save this registration somewhere.
type Registration struct {
Resource string `json:"resource,omitempty"`
ID int `json:"id"`
Key jose.JsonWebKey `json:"key"`
Contact []string `json:"contact"`
Agreement string `json:"agreement,omitempty"`
Authorizations string `json:"authorizations,omitempty"`
Certificates string `json:"certificates,omitempty"`
// RecoveryKey recoveryKeyMessage `json:"recoveryKey,omitempty"`
}
// RegistrationResource represents all important informations about a registration
// of which the client needs to keep track itself.
type RegistrationResource struct {
Body Registration `json:"body,omitempty"`
URI string `json:"uri,omitempty"`
NewAuthzURL string `json:"new_authzr_uri,omitempty"`
TosURL string `json:"terms_of_service,omitempty"`
}
type authorizationResource struct {
Body authorization
Domain string
NewCertURL string
AuthURL string
}
type authorization struct {
Resource string `json:"resource,omitempty"`
Identifier identifier `json:"identifier"`
Status string `json:"status,omitempty"`
Expires time.Time `json:"expires,omitempty"`
Challenges []challenge `json:"challenges,omitempty"`
Combinations [][]int `json:"combinations,omitempty"`
}
type identifier struct {
Type string `json:"type"`
Value string `json:"value"`
}
type validationRecord struct {
URI string `json:"url,omitempty"`
Hostname string `json:"hostname,omitempty"`
Port string `json:"port,omitempty"`
ResolvedAddresses []string `json:"addressesResolved,omitempty"`
UsedAddress string `json:"addressUsed,omitempty"`
}
type challenge struct {
Resource string `json:"resource,omitempty"`
Type Challenge `json:"type,omitempty"`
Status string `json:"status,omitempty"`
URI string `json:"uri,omitempty"`
Token string `json:"token,omitempty"`
KeyAuthorization string `json:"keyAuthorization,omitempty"`
TLS bool `json:"tls,omitempty"`
Iterations int `json:"n,omitempty"`
Error RemoteError `json:"error,omitempty"`
ValidationRecords []validationRecord `json:"validationRecord,omitempty"`
}
type csrMessage struct {
Resource string `json:"resource,omitempty"`
Csr string `json:"csr"`
Authorizations []string `json:"authorizations"`
}
type revokeCertMessage struct {
Resource string `json:"resource"`
Certificate string `json:"certificate"`
}
// CertificateResource represents a CA issued certificate.
// PrivateKey and Certificate are both already PEM encoded
// and can be directly written to disk. Certificate may
// be a certificate bundle, depending on the options supplied
// to create it.
type CertificateResource struct {
Domain string `json:"domain"`
CertURL string `json:"certUrl"`
CertStableURL string `json:"certStableUrl"`
AccountRef string `json:"accountRef,omitempty"`
PrivateKey []byte `json:"-"`
Certificate []byte `json:"-"`
}

1
acme/pop_challenge.go Normal file
View file

@ -0,0 +1 @@
package acme

View file

@ -1,28 +1,28 @@
package challenge
package acme
import "time"
// Provider enables implementing a custom challenge
// ChallengeProvider enables implementing a custom challenge
// provider. Present presents the solution to a challenge available to
// be solved. CleanUp will be called by the challenge if Present ends
// in a non-error state.
type Provider interface {
type ChallengeProvider interface {
Present(domain, token, keyAuth string) error
CleanUp(domain, token, keyAuth string) error
}
// ProviderTimeout allows for implementing a
// Provider where an unusually long timeout is required when
// ChallengeProviderTimeout allows for implementing a
// ChallengeProvider where an unusually long timeout is required when
// waiting for an ACME challenge to be satisfied, such as when
// checking for DNS record propagation. If an implementor of a
// Provider provides a Timeout method, then the return values
// checking for DNS record progagation. If an implementor of a
// ChallengeProvider provides a Timeout method, then the return values
// of the Timeout method will be used when appropriate by the acme
// package. The interval value is the time between checks.
//
// The default values used for timeout and interval are 60 seconds and
// 2 seconds respectively. These are used when no Timeout method is
// defined for the Provider.
type ProviderTimeout interface {
Provider
// defined for the ChallengeProvider.
type ChallengeProviderTimeout interface {
ChallengeProvider
Timeout() (timeout, interval time.Duration)
}

67
acme/tls_sni_challenge.go Normal file
View file

@ -0,0 +1,67 @@
package acme
import (
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"fmt"
"log"
)
type tlsSNIChallenge struct {
jws *jws
validate validateFunc
provider ChallengeProvider
}
func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error {
// FIXME: https://github.com/ietf-wg-acme/acme/pull/22
// Currently we implement this challenge to track boulder, not the current spec!
logf("[INFO][%s] acme: Trying to solve TLS-SNI-01", domain)
// Generate the Key Authorization for the challenge
keyAuth, err := getKeyAuthorization(chlng.Token, t.jws.privKey)
if err != nil {
return err
}
err = t.provider.Present(domain, chlng.Token, keyAuth)
if err != nil {
return fmt.Errorf("[%s] error presenting token: %v", domain, err)
}
defer func() {
err := t.provider.CleanUp(domain, chlng.Token, keyAuth)
if err != nil {
log.Printf("[%s] error cleaning up: %v", domain, err)
}
}()
return t.validate(t.jws, domain, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth})
}
// TLSSNI01ChallengeCert returns a certificate for the `tls-sni-01` challenge
func TLSSNI01ChallengeCert(keyAuth string) (tls.Certificate, error) {
// generate a new RSA key for the certificates
tempPrivKey, err := generatePrivateKey(RSA2048)
if err != nil {
return tls.Certificate{}, err
}
rsaPrivKey := tempPrivKey.(*rsa.PrivateKey)
rsaPrivPEM := pemEncode(rsaPrivKey)
zBytes := sha256.Sum256([]byte(keyAuth))
z := hex.EncodeToString(zBytes[:sha256.Size])
domain := fmt.Sprintf("%s.%s.acme.invalid", z[:32], z[32:])
tempCertPEM, err := generatePemCert(rsaPrivKey, domain)
if err != nil {
return tls.Certificate{}, err
}
certificate, err := tls.X509KeyPair(tempCertPEM, rsaPrivPEM)
if err != nil {
return tls.Certificate{}, err
}
return certificate, nil
}

View file

@ -0,0 +1,62 @@
package acme
import (
"crypto/tls"
"fmt"
"net"
"net/http"
)
// TLSProviderServer implements ChallengeProvider for `TLS-SNI-01` challenge
// It may be instantiated without using the NewTLSProviderServer function if
// you want only to use the default values.
type TLSProviderServer struct {
iface string
port string
done chan bool
listener net.Listener
}
// NewTLSProviderServer creates a new TLSProviderServer on the selected interface and port.
// Setting iface and / or port to an empty string will make the server fall back to
// the "any" interface and port 443 respectively.
func NewTLSProviderServer(iface, port string) *TLSProviderServer {
return &TLSProviderServer{iface: iface, port: port}
}
// Present makes the keyAuth available as a cert
func (s *TLSProviderServer) Present(domain, token, keyAuth string) error {
if s.port == "" {
s.port = "443"
}
cert, err := TLSSNI01ChallengeCert(keyAuth)
if err != nil {
return err
}
tlsConf := new(tls.Config)
tlsConf.Certificates = []tls.Certificate{cert}
s.listener, err = tls.Listen("tcp", net.JoinHostPort(s.iface, s.port), tlsConf)
if err != nil {
return fmt.Errorf("Could not start HTTPS server for challenge -> %v", err)
}
s.done = make(chan bool)
go func() {
http.Serve(s.listener, nil)
s.done <- true
}()
return nil
}
// CleanUp closes the HTTP server.
func (s *TLSProviderServer) CleanUp(domain, token, keyAuth string) error {
if s.listener == nil {
return nil
}
s.listener.Close()
<-s.done
return nil
}

View file

@ -0,0 +1,65 @@
package acme
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"fmt"
"strings"
"testing"
)
func TestTLSSNIChallenge(t *testing.T) {
privKey, _ := rsa.GenerateKey(rand.Reader, 512)
j := &jws{privKey: privKey}
clientChallenge := challenge{Type: TLSSNI01, Token: "tlssni1"}
mockValidate := func(_ *jws, _, _ string, chlng challenge) error {
conn, err := tls.Dial("tcp", "localhost:23457", &tls.Config{
InsecureSkipVerify: true,
})
if err != nil {
t.Errorf("Expected to connect to challenge server without an error. %s", err.Error())
}
// Expect the server to only return one certificate
connState := conn.ConnectionState()
if count := len(connState.PeerCertificates); count != 1 {
t.Errorf("Expected the challenge server to return exactly one certificate but got %d", count)
}
remoteCert := connState.PeerCertificates[0]
if count := len(remoteCert.DNSNames); count != 1 {
t.Errorf("Expected the challenge certificate to have exactly one DNSNames entry but had %d", count)
}
zBytes := sha256.Sum256([]byte(chlng.KeyAuthorization))
z := hex.EncodeToString(zBytes[:sha256.Size])
domain := fmt.Sprintf("%s.%s.acme.invalid", z[:32], z[32:])
if remoteCert.DNSNames[0] != domain {
t.Errorf("Expected the challenge certificate DNSName to match %s but was %s", domain, remoteCert.DNSNames[0])
}
return nil
}
solver := &tlsSNIChallenge{jws: j, validate: mockValidate, provider: &TLSProviderServer{port: "23457"}}
if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil {
t.Errorf("Solve error: got %v, want nil", err)
}
}
func TestTLSSNIChallengeInvalidPort(t *testing.T) {
privKey, _ := rsa.GenerateKey(rand.Reader, 128)
j := &jws{privKey: privKey}
clientChallenge := challenge{Type: TLSSNI01, Token: "tlssni2"}
solver := &tlsSNIChallenge{jws: j, validate: stubValidate, provider: &TLSProviderServer{port: "123456"}}
if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil {
t.Errorf("Solve error: got %v, want error", err)
} else if want := "invalid port 123456"; !strings.HasSuffix(err.Error(), want) {
t.Errorf("Solve error: got %q, want suffix %q", err.Error(), want)
}
}

29
acme/utils.go Normal file
View file

@ -0,0 +1,29 @@
package acme
import (
"fmt"
"time"
)
// WaitFor polls the given function 'f', once every 'interval', up to 'timeout'.
func WaitFor(timeout, interval time.Duration, f func() (bool, error)) error {
var lastErr string
timeup := time.After(timeout)
for {
select {
case <-timeup:
return fmt.Errorf("Time limit exceeded. Last error: %s", lastErr)
default:
}
stop, err := f()
if stop {
return nil
}
if err != nil {
lastErr = err.Error()
}
time.Sleep(interval)
}
}

View file

@ -1,19 +1,20 @@
package wait
package acme
import (
"testing"
"time"
)
func TestForTimeout(t *testing.T) {
func TestWaitForTimeout(t *testing.T) {
c := make(chan error)
go func() {
c <- For("", 3*time.Second, 1*time.Second, func() (bool, error) {
err := WaitFor(3*time.Second, 1*time.Second, func() (bool, error) {
return false, nil
})
c <- err
}()
timeout := time.After(6 * time.Second)
timeout := time.After(4 * time.Second)
select {
case <-timeout:
t.Fatal("timeout exceeded")
@ -21,6 +22,5 @@ func TestForTimeout(t *testing.T) {
if err == nil {
t.Errorf("expected timeout error; got %v", err)
}
t.Logf("%v", err)
}
}

View file

@ -1,10 +0,0 @@
# syntax=docker/dockerfile:1.4
FROM alpine:3
RUN apk --no-cache --no-progress add git ca-certificates tzdata \
&& rm -rf /var/cache/apk/*
COPY lego /
ENTRYPOINT ["/lego"]
EXPOSE 80

View file

@ -1,320 +0,0 @@
package certcrypto
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"strings"
"time"
"golang.org/x/crypto/ocsp"
)
// Constants for all key types we support.
const (
EC256 = KeyType("P256")
EC384 = KeyType("P384")
RSA2048 = KeyType("2048")
RSA3072 = KeyType("3072")
RSA4096 = KeyType("4096")
RSA8192 = KeyType("8192")
)
const (
// OCSPGood means that the certificate is valid.
OCSPGood = ocsp.Good
// OCSPRevoked means that the certificate has been deliberately revoked.
OCSPRevoked = ocsp.Revoked
// OCSPUnknown means that the OCSP responder doesn't know about the certificate.
OCSPUnknown = ocsp.Unknown
// OCSPServerFailed means that the OCSP responder failed to process the request.
OCSPServerFailed = ocsp.ServerFailed
)
// Constants for OCSP must staple.
var (
tlsFeatureExtensionOID = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24}
ocspMustStapleFeature = []byte{0x30, 0x03, 0x02, 0x01, 0x05}
)
// KeyType represents the key algo as well as the key size or curve to use.
type KeyType string
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
for {
certDERBlock, bundle = pem.Decode(bundle)
if certDERBlock == nil {
break
}
if certDERBlock.Type == "CERTIFICATE" {
cert, err := x509.ParseCertificate(certDERBlock.Bytes)
if err != nil {
return nil, err
}
certificates = append(certificates, cert)
}
}
if len(certificates) == 0 {
return nil, errors.New("no certificates were found while parsing the bundle")
}
return certificates, nil
}
// ParsePEMPrivateKey parses a private key from key, which is a PEM block.
// Borrowed from Go standard library, to handle various private key and PEM block types.
// https://github.com/golang/go/blob/693748e9fa385f1e2c3b91ca9acbb6c0ad2d133d/src/crypto/tls/tls.go#L291-L308
// https://github.com/golang/go/blob/693748e9fa385f1e2c3b91ca9acbb6c0ad2d133d/src/crypto/tls/tls.go#L238)
func ParsePEMPrivateKey(key []byte) (crypto.PrivateKey, error) {
keyBlockDER, _ := pem.Decode(key)
if keyBlockDER == nil {
return nil, fmt.Errorf("invalid PEM block")
}
if keyBlockDER.Type != "PRIVATE KEY" && !strings.HasSuffix(keyBlockDER.Type, " PRIVATE KEY") {
return nil, fmt.Errorf("unknown PEM header %q", keyBlockDER.Type)
}
if key, err := x509.ParsePKCS1PrivateKey(keyBlockDER.Bytes); err == nil {
return key, nil
}
if key, err := x509.ParsePKCS8PrivateKey(keyBlockDER.Bytes); err == nil {
switch key := key.(type) {
case *rsa.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey:
return key, nil
default:
return nil, fmt.Errorf("found unknown private key type in PKCS#8 wrapping: %T", key)
}
}
if key, err := x509.ParseECPrivateKey(keyBlockDER.Bytes); err == nil {
return key, nil
}
return nil, errors.New("failed to parse private key")
}
func GeneratePrivateKey(keyType KeyType) (crypto.PrivateKey, error) {
switch keyType {
case EC256:
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
case EC384:
return ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
case RSA2048:
return rsa.GenerateKey(rand.Reader, 2048)
case RSA3072:
return rsa.GenerateKey(rand.Reader, 3072)
case RSA4096:
return rsa.GenerateKey(rand.Reader, 4096)
case RSA8192:
return rsa.GenerateKey(rand.Reader, 8192)
}
return nil, fmt.Errorf("invalid KeyType: %s", keyType)
}
func GenerateCSR(privateKey crypto.PrivateKey, domain string, san []string, mustStaple bool) ([]byte, error) {
var dnsNames []string
var ipAddresses []net.IP
for _, altname := range san {
if ip := net.ParseIP(altname); ip != nil {
ipAddresses = append(ipAddresses, ip)
} else {
dnsNames = append(dnsNames, altname)
}
}
template := x509.CertificateRequest{
Subject: pkix.Name{CommonName: domain},
DNSNames: dnsNames,
IPAddresses: ipAddresses,
}
if mustStaple {
template.ExtraExtensions = append(template.ExtraExtensions, pkix.Extension{
Id: tlsFeatureExtensionOID,
Value: ocspMustStapleFeature,
})
}
return x509.CreateCertificateRequest(rand.Reader, &template, privateKey)
}
func PEMEncode(data interface{}) []byte {
return pem.EncodeToMemory(PEMBlock(data))
}
func PEMBlock(data interface{}) *pem.Block {
var pemBlock *pem.Block
switch key := data.(type) {
case *ecdsa.PrivateKey:
keyBytes, _ := x509.MarshalECPrivateKey(key)
pemBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}
case *rsa.PrivateKey:
pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
case *x509.CertificateRequest:
pemBlock = &pem.Block{Type: "CERTIFICATE REQUEST", Bytes: key.Raw}
case DERCertificateBytes:
pemBlock = &pem.Block{Type: "CERTIFICATE", Bytes: []byte(data.(DERCertificateBytes))}
}
return pemBlock
}
func pemDecode(data []byte) (*pem.Block, error) {
pemBlock, _ := pem.Decode(data)
if pemBlock == nil {
return nil, errors.New("PEM decode did not yield a valid block. Is the certificate in the right format?")
}
return pemBlock, nil
}
func PemDecodeTox509CSR(data []byte) (*x509.CertificateRequest, error) {
pemBlock, err := pemDecode(data)
if pemBlock == nil {
return nil, err
}
if pemBlock.Type != "CERTIFICATE REQUEST" && pemBlock.Type != "NEW CERTIFICATE REQUEST" {
return nil, errors.New("PEM block is not a certificate request")
}
return x509.ParseCertificateRequest(pemBlock.Bytes)
}
// ParsePEMCertificate returns Certificate from a PEM encoded certificate.
// The certificate has to be PEM encoded. Any other encodings like DER will fail.
func ParsePEMCertificate(cert []byte) (*x509.Certificate, error) {
pemBlock, err := pemDecode(cert)
if pemBlock == nil {
return nil, err
}
// from a DER encoded certificate
return x509.ParseCertificate(pemBlock.Bytes)
}
func ExtractDomains(cert *x509.Certificate) []string {
var domains []string
if cert.Subject.CommonName != "" {
domains = append(domains, cert.Subject.CommonName)
}
// Check for SAN certificate
for _, sanDomain := range cert.DNSNames {
if sanDomain == cert.Subject.CommonName {
continue
}
domains = append(domains, sanDomain)
}
commonNameIP := net.ParseIP(cert.Subject.CommonName)
for _, sanIP := range cert.IPAddresses {
if !commonNameIP.Equal(sanIP) {
domains = append(domains, sanIP.String())
}
}
return domains
}
func ExtractDomainsCSR(csr *x509.CertificateRequest) []string {
var domains []string
if csr.Subject.CommonName != "" {
domains = append(domains, csr.Subject.CommonName)
}
// loop over the SubjectAltName DNS names
for _, sanName := range csr.DNSNames {
if containsSAN(domains, sanName) {
// Duplicate; skip this name
continue
}
// Name is unique
domains = append(domains, sanName)
}
cnip := net.ParseIP(csr.Subject.CommonName)
for _, sanIP := range csr.IPAddresses {
if !cnip.Equal(sanIP) {
domains = append(domains, sanIP.String())
}
}
return domains
}
func containsSAN(domains []string, sanName string) bool {
for _, existingName := range domains {
if existingName == sanName {
return true
}
}
return false
}
func GeneratePemCert(privateKey *rsa.PrivateKey, domain string, extensions []pkix.Extension) ([]byte, error) {
derBytes, err := generateDerCert(privateKey, time.Time{}, domain, extensions)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}), nil
}
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
}
if expiration.IsZero() {
expiration = time.Now().AddDate(1, 0, 0)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: "ACME Challenge TEMP",
},
NotBefore: time.Now(),
NotAfter: expiration,
KeyUsage: x509.KeyUsageKeyEncipherment,
BasicConstraintsValid: true,
ExtraExtensions: extensions,
}
// handling SAN filling as type suspected
if ip := net.ParseIP(domain); ip != nil {
template.IPAddresses = []net.IP{ip}
} else {
template.DNSNames = []string{domain}
}
return x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
}

View file

@ -1,177 +0,0 @@
package certcrypto
import (
"bytes"
"crypto"
"crypto/rand"
"crypto/rsa"
"encoding/pem"
"regexp"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGeneratePrivateKey(t *testing.T) {
key, err := GeneratePrivateKey(RSA2048)
require.NoError(t, err, "Error generating private key")
assert.NotNil(t, key)
}
func TestGenerateCSR(t *testing.T) {
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
require.NoError(t, err, "Error generating private key")
type expected struct {
len int
error bool
}
testCases := []struct {
desc string
privateKey crypto.PrivateKey
domain string
san []string
mustStaple bool
expected expected
}{
{
desc: "without SAN",
privateKey: privateKey,
domain: "lego.acme",
mustStaple: true,
expected: expected{len: 245},
},
{
desc: "without SAN",
privateKey: privateKey,
domain: "lego.acme",
san: []string{},
mustStaple: true,
expected: expected{len: 245},
},
{
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},
},
{
desc: "no domain",
privateKey: privateKey,
domain: "",
mustStaple: true,
expected: expected{len: 225},
},
{
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},
},
{
desc: "private key nil",
privateKey: nil,
domain: "fizz.buzz",
mustStaple: true,
expected: expected{error: true},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
csr, err := GenerateCSR(test.privateKey, test.domain, test.san, test.mustStaple)
if test.expected.error {
require.Error(t, err)
} else {
require.NoError(t, err, "Error generating CSR")
assert.NotEmpty(t, csr)
assert.Len(t, csr, test.expected.len)
}
})
}
}
func TestPEMEncode(t *testing.T) {
buf := bytes.NewBufferString("TestingRSAIsSoMuchFun")
reader := MockRandReader{b: buf}
key, err := rsa.GenerateKey(reader, 32)
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))
}
func TestParsePEMCertificate(t *testing.T) {
privateKey, err := GeneratePrivateKey(RSA2048)
require.NoError(t, err, "Error generating private key")
expiration := time.Now().Add(365).Round(time.Second)
certBytes, err := generateDerCert(privateKey.(*rsa.PrivateKey), expiration, "test.com", nil)
require.NoError(t, err, "Error generating cert")
buf := bytes.NewBufferString("TestingRSAIsSoMuchFun")
// Some random string should return an error.
cert, err := ParsePEMCertificate(buf.Bytes())
require.Errorf(t, err, "returned %v", cert)
// A DER encoded certificate should return an error.
_, err = ParsePEMCertificate(certBytes)
require.Error(t, err, "Expected to return an error for DER certificates")
// A PEM encoded certificate should work ok.
pemCert := PEMEncode(DERCertificateBytes(certBytes))
cert, err = ParsePEMCertificate(pemCert)
require.NoError(t, err)
assert.Equal(t, expiration.UTC(), cert.NotAfter)
}
func TestParsePEMPrivateKey(t *testing.T) {
privateKey, err := GeneratePrivateKey(RSA2048)
require.NoError(t, err, "Error generating private key")
pemPrivateKey := PEMEncode(privateKey)
// Decoding a key should work and create an identical key to the original
decoded, err := ParsePEMPrivateKey(pemPrivateKey)
require.NoError(t, err)
assert.Equal(t, decoded, privateKey)
// Decoding a PEM block that doesn't contain a private key should error
_, err = ParsePEMPrivateKey(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE"}))
require.Errorf(t, err, "Expected to return an error for non-private key input")
// Decoding a PEM block that doesn't actually contain a key should error
_, err = ParsePEMPrivateKey(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY"}))
require.Errorf(t, err, "Expected to return an error for empty input")
// Decoding non-PEM input should return an error
_, 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)
}

View file

@ -1,81 +0,0 @@
package certificate
import (
"time"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/log"
)
const (
// overallRequestLimit is the overall number of request per second
// limited on the "new-reg", "new-authz" and "new-cert" endpoints.
// From the documentation the limitation is 20 requests per second,
// but using 20 as value doesn't work but 18 do.
overallRequestLimit = 18
)
func (c *Certifier) getAuthorizations(order acme.ExtendedOrder) ([]acme.Authorization, error) {
resc, errc := make(chan acme.Authorization), make(chan domainError)
delay := time.Second / overallRequestLimit
for _, authzURL := range order.Authorizations {
time.Sleep(delay)
go func(authzURL string) {
authz, err := c.core.Authorizations.Get(authzURL)
if err != nil {
errc <- domainError{Domain: authz.Identifier.Value, Error: err}
return
}
resc <- authz
}(authzURL)
}
var responses []acme.Authorization
failures := make(obtainError)
for i := 0; i < len(order.Authorizations); i++ {
select {
case res := <-resc:
responses = append(responses, res)
case err := <-errc:
failures[err.Domain] = err.Error
}
}
for i, auth := range order.Authorizations {
log.Infof("[%s] AuthURL: %s", order.Identifiers[i].Value, auth)
}
close(resc)
close(errc)
// be careful to not return an empty failures map;
// even if empty, they become non-nil error values
if len(failures) > 0 {
return responses, failures
}
return responses, nil
}
func (c *Certifier) deactivateAuthorizations(order acme.ExtendedOrder, force bool) {
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)
continue
}
if auth.Status == acme.StatusValid && !force {
log.Infof("Skipping deactivating of valid auth: %s", authzURL)
continue
}
log.Infof("Deactivating auth: %s", authzURL)
if c.core.Authorizations.Deactivate(authzURL) != nil {
log.Infof("Unable to deactivate the authorization: %s", authzURL)
}
}
}

View file

@ -1,662 +0,0 @@
package certificate
import (
"bytes"
"crypto"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/wait"
"golang.org/x/crypto/ocsp"
"golang.org/x/net/idna"
)
// maxBodySize is the maximum size of body that we will read.
const maxBodySize = 1024 * 1024
// Resource represents a CA issued certificate.
// PrivateKey, Certificate and IssuerCertificate are all
// already PEM encoded and can be directly written to disk.
// Certificate may be a certificate bundle,
// depending on the options supplied to create it.
type Resource struct {
Domain string `json:"domain"`
CertURL string `json:"certUrl"`
CertStableURL string `json:"certStableUrl"`
PrivateKey []byte `json:"-"`
Certificate []byte `json:"-"`
IssuerCertificate []byte `json:"-"`
CSR []byte `json:"-"`
}
// ObtainRequest The request to obtain certificate.
//
// The first domain in domains is used for the CommonName field of the certificate,
// all other domains are added using the Subject Alternate Names extension.
//
// A new private key is generated for every invocation of the function Obtain.
// If you do not want that you can supply your own private key in the privateKey parameter.
// If this parameter is non-nil it will be used instead of generating a new one.
//
// If `Bundle` is true, the `[]byte` contains both the issuer certificate and your issued certificate as a bundle.
//
// 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
NotBefore time.Time
NotAfter time.Time
Bundle bool
PreferredChain string
AlwaysDeactivateAuthorizations bool
}
// ObtainForCSRRequest The request to obtain a certificate matching the CSR passed into it.
//
// If `Bundle` is true, the `[]byte` contains both the issuer certificate and your issued certificate as a bundle.
//
// 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 ObtainForCSRRequest struct {
CSR *x509.CertificateRequest
NotBefore time.Time
NotAfter time.Time
Bundle bool
PreferredChain string
AlwaysDeactivateAuthorizations bool
}
type resolver interface {
Solve(authorizations []acme.Authorization) error
}
type CertifierOptions struct {
KeyType certcrypto.KeyType
Timeout time.Duration
}
// Certifier A service to obtain/renew/revoke certificates.
type Certifier struct {
core *api.Core
resolver resolver
options CertifierOptions
}
// NewCertifier creates a Certifier.
func NewCertifier(core *api.Core, resolver resolver, options CertifierOptions) *Certifier {
return &Certifier{
core: core,
resolver: resolver,
options: options,
}
}
// Obtain tries to obtain a single certificate using all domains passed into it.
//
// This function will never return a partial certificate.
// If one domain in the list fails, the whole certificate will fail.
func (c *Certifier) Obtain(request ObtainRequest) (*Resource, error) {
if len(request.Domains) == 0 {
return nil, errors.New("no domains to obtain a certificate for")
}
domains := sanitizeDomain(request.Domains)
if request.Bundle {
log.Infof("[%s] acme: Obtaining bundled SAN certificate", strings.Join(domains, ", "))
} else {
log.Infof("[%s] acme: Obtaining SAN certificate", strings.Join(domains, ", "))
}
orderOpts := &api.OrderOptions{
NotBefore: request.NotBefore,
NotAfter: request.NotAfter,
}
order, err := c.core.Orders.NewWithOptions(domains, orderOpts)
if err != nil {
return nil, err
}
authz, err := c.getAuthorizations(order)
if err != nil {
// If any challenge fails, return. Do not generate partial SAN certificates.
c.deactivateAuthorizations(order, request.AlwaysDeactivateAuthorizations)
return nil, err
}
err = c.resolver.Solve(authz)
if err != nil {
// If any challenge fails, return. Do not generate partial SAN certificates.
c.deactivateAuthorizations(order, request.AlwaysDeactivateAuthorizations)
return nil, err
}
log.Infof("[%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", "))
failures := make(obtainError)
cert, err := c.getForOrder(domains, order, request.Bundle, request.PrivateKey, request.MustStaple, request.PreferredChain)
if err != nil {
for _, auth := range authz {
failures[challenge.GetTargetedDomain(auth)] = err
}
}
if request.AlwaysDeactivateAuthorizations {
c.deactivateAuthorizations(order, true)
}
// Do not return an empty failures map, because
// it would still be a non-nil error value
if len(failures) > 0 {
return cert, failures
}
return cert, nil
}
// ObtainForCSR tries to obtain a certificate matching the CSR passed into it.
//
// The domains are inferred from the CommonName and SubjectAltNames, if any.
// The private key for this CSR is not required.
//
// If bundle is true, the []byte contains both the issuer certificate and your issued certificate as a bundle.
//
// This function will never return a partial certificate.
// If one domain in the list fails, the whole certificate will fail.
func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error) {
if request.CSR == nil {
return nil, errors.New("cannot obtain resource for CSR: CSR is missing")
}
// figure out what domains it concerns
// start with the common name
domains := certcrypto.ExtractDomainsCSR(request.CSR)
if request.Bundle {
log.Infof("[%s] acme: Obtaining bundled SAN certificate given a CSR", strings.Join(domains, ", "))
} else {
log.Infof("[%s] acme: Obtaining SAN certificate given a CSR", strings.Join(domains, ", "))
}
orderOpts := &api.OrderOptions{
NotBefore: request.NotBefore,
NotAfter: request.NotAfter,
}
order, err := c.core.Orders.NewWithOptions(domains, orderOpts)
if err != nil {
return nil, err
}
authz, err := c.getAuthorizations(order)
if err != nil {
// If any challenge fails, return. Do not generate partial SAN certificates.
c.deactivateAuthorizations(order, request.AlwaysDeactivateAuthorizations)
return nil, err
}
err = c.resolver.Solve(authz)
if err != nil {
// If any challenge fails, return. Do not generate partial SAN certificates.
c.deactivateAuthorizations(order, request.AlwaysDeactivateAuthorizations)
return nil, err
}
log.Infof("[%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", "))
failures := make(obtainError)
cert, err := c.getForCSR(domains, order, request.Bundle, request.CSR.Raw, nil, request.PreferredChain)
if err != nil {
for _, auth := range authz {
failures[challenge.GetTargetedDomain(auth)] = err
}
}
if request.AlwaysDeactivateAuthorizations {
c.deactivateAuthorizations(order, true)
}
if cert != nil {
// Add the CSR to the certificate so that it can be used for renewals.
cert.CSR = certcrypto.PEMEncode(request.CSR)
}
// Do not return an empty failures map,
// because it would still be a non-nil error value
if len(failures) > 0 {
return cert, failures
}
return cert, nil
}
func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bundle bool, privateKey crypto.PrivateKey, mustStaple bool, preferredChain string) (*Resource, error) {
if privateKey == nil {
var err error
privateKey, err = certcrypto.GeneratePrivateKey(c.options.KeyType)
if err != nil {
return nil, err
}
}
// Determine certificate name(s) based on the authorization resources
commonName := domains[0]
// RFC8555 Section 7.4 "Applying for Certificate Issuance"
// https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4
// says:
// Clients SHOULD NOT make any assumptions about the sort order of
// "identifiers" or "authorizations" elements in the returned order
// object.
san := []string{commonName}
for _, auth := range order.Identifiers {
if auth.Value != commonName {
san = append(san, auth.Value)
}
}
// TODO: should the CSR be customizable?
csr, err := certcrypto.GenerateCSR(privateKey, commonName, san, mustStaple)
if err != nil {
return nil, err
}
return c.getForCSR(domains, order, bundle, csr, certcrypto.PEMEncode(privateKey), preferredChain)
}
func (c *Certifier) getForCSR(domains []string, order acme.ExtendedOrder, bundle bool, csr, privateKeyPem []byte, preferredChain string) (*Resource, error) {
respOrder, err := c.core.Orders.UpdateForCSR(order.Finalize, csr)
if err != nil {
return nil, err
}
commonName := domains[0]
certRes := &Resource{
Domain: commonName,
CertURL: respOrder.Certificate,
PrivateKey: privateKeyPem,
}
if respOrder.Status == acme.StatusValid {
// if the certificate is available right away, short cut!
ok, errR := c.checkResponse(respOrder, certRes, bundle, preferredChain)
if errR != nil {
return nil, errR
}
if ok {
return certRes, nil
}
}
timeout := c.options.Timeout
if c.options.Timeout <= 0 {
timeout = 30 * time.Second
}
err = wait.For("certificate", timeout, timeout/60, func() (bool, error) {
ord, errW := c.core.Orders.Get(order.Location)
if errW != nil {
return false, errW
}
done, errW := c.checkResponse(ord, certRes, bundle, preferredChain)
if errW != nil {
return false, errW
}
return done, nil
})
return certRes, err
}
// checkResponse checks to see if the certificate is ready and a link is contained in the response.
//
// If so, loads it into certRes and returns true.
// If the cert is not yet ready, it returns false.
//
// The certRes input should already have the Domain (common name) field populated.
//
// If bundle is true, the certificate will be bundled with the issuer's cert.
func (c *Certifier) checkResponse(order acme.ExtendedOrder, certRes *Resource, bundle bool, preferredChain string) (bool, error) {
valid, err := checkOrderStatus(order)
if err != nil || !valid {
return valid, err
}
certs, err := c.core.Certificates.GetAll(order.Certificate, bundle)
if err != nil {
return false, err
}
// Set the default certificate
certRes.IssuerCertificate = certs[order.Certificate].Issuer
certRes.Certificate = certs[order.Certificate].Cert
certRes.CertURL = order.Certificate
certRes.CertStableURL = order.Certificate
if preferredChain == "" {
log.Infof("[%s] Server responded with a certificate.", certRes.Domain)
return true, nil
}
for link, cert := range certs {
ok, err := hasPreferredChain(cert.Issuer, preferredChain)
if err != nil {
return false, err
}
if ok {
log.Infof("[%s] Server responded with a certificate for the preferred certificate chains %q.", certRes.Domain, preferredChain)
certRes.IssuerCertificate = cert.Issuer
certRes.Certificate = cert.Cert
certRes.CertURL = link
certRes.CertStableURL = link
return true, nil
}
}
log.Infof("lego has been configured to prefer certificate chains with issuer %q, but no chain from the CA matched this issuer. Using the default certificate chain instead.", preferredChain)
return true, nil
}
// Revoke takes a PEM encoded certificate or bundle and tries to revoke it at the CA.
func (c *Certifier) Revoke(cert []byte) error {
return c.RevokeWithReason(cert, nil)
}
// RevokeWithReason takes a PEM encoded certificate or bundle and tries to revoke it at the CA.
func (c *Certifier) RevokeWithReason(cert []byte, reason *uint) error {
certificates, err := certcrypto.ParsePEMBundle(cert)
if err != nil {
return err
}
x509Cert := certificates[0]
if x509Cert.IsCA {
return errors.New("certificate bundle starts with a CA certificate")
}
revokeMsg := acme.RevokeCertMessage{
Certificate: base64.RawURLEncoding.EncodeToString(x509Cert.Raw),
Reason: reason,
}
return c.core.Certificates.Revoke(revokeMsg)
}
// RenewOptions options used by Certifier.RenewWithOptions.
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
AlwaysDeactivateAuthorizations bool
// Not supported for CSR request.
MustStaple bool
}
// Renew takes a Resource and tries to renew the certificate.
//
// If the renewal process succeeds, the new certificate will be returned in a new CertResource.
// Please be aware that this function will return a new certificate in ANY case that is not an error.
// If the server does not provide us with a new cert on a GET request to the CertURL
// this function will start a new-cert flow where a new certificate gets generated.
//
// 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{
Bundle: bundle,
PreferredChain: preferredChain,
MustStaple: mustStaple,
})
}
// RenewWithOptions takes a Resource and tries to renew the certificate.
//
// If the renewal process succeeds, the new certificate will be returned in a new CertResource.
// Please be aware that this function will return a new certificate in ANY case that is not an error.
// If the server does not provide us with a new cert on a GET request to the CertURL
// this function will start a new-cert flow where a new certificate gets generated.
//
// 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.
func (c *Certifier) RenewWithOptions(certRes Resource, options *RenewOptions) (*Resource, error) {
// Input certificate is PEM encoded.
// Decode it here as we may need the decoded cert later on in the renewal process.
// The input may be a bundle or a single certificate.
certificates, err := certcrypto.ParsePEMBundle(certRes.Certificate)
if err != nil {
return nil, err
}
x509Cert := certificates[0]
if x509Cert.IsCA {
return nil, fmt.Errorf("[%s] Certificate bundle starts with a CA certificate", certRes.Domain)
}
// This is just meant to be informal for the user.
timeLeft := x509Cert.NotAfter.Sub(time.Now().UTC())
log.Infof("[%s] acme: Trying renewal with %d hours remaining", certRes.Domain, int(timeLeft.Hours()))
// We always need to request a new certificate to renew.
// Start by checking to see if the certificate was based off a CSR,
// and use that if it's defined.
if len(certRes.CSR) > 0 {
csr, errP := certcrypto.PemDecodeTox509CSR(certRes.CSR)
if errP != nil {
return nil, errP
}
request := ObtainForCSRRequest{CSR: csr}
if options != nil {
request.NotBefore = options.NotBefore
request.NotAfter = options.NotAfter
request.Bundle = options.Bundle
request.PreferredChain = options.PreferredChain
request.AlwaysDeactivateAuthorizations = options.AlwaysDeactivateAuthorizations
}
return c.ObtainForCSR(request)
}
var privateKey crypto.PrivateKey
if certRes.PrivateKey != nil {
privateKey, err = certcrypto.ParsePEMPrivateKey(certRes.PrivateKey)
if err != nil {
return nil, err
}
}
request := ObtainRequest{
Domains: certcrypto.ExtractDomains(x509Cert),
PrivateKey: privateKey,
}
if options != nil {
request.MustStaple = options.MustStaple
request.NotBefore = options.NotBefore
request.NotAfter = options.NotAfter
request.Bundle = options.Bundle
request.PreferredChain = options.PreferredChain
request.AlwaysDeactivateAuthorizations = options.AlwaysDeactivateAuthorizations
}
return c.Obtain(request)
}
// GetOCSP takes a PEM encoded cert or cert bundle returning the raw OCSP response,
// the parsed response, and an error, if any.
//
// The returned []byte can be passed directly into the OCSPStaple property of a tls.Certificate.
// If the bundle only contains the issued certificate,
// this function will try to get the issuer certificate from the IssuingCertificateURL in the certificate.
//
// If the []byte and/or ocsp.Response return values are nil, the OCSP status may be assumed OCSPUnknown.
func (c *Certifier) GetOCSP(bundle []byte) ([]byte, *ocsp.Response, error) {
certificates, err := certcrypto.ParsePEMBundle(bundle)
if err != nil {
return nil, nil, err
}
// We expect the certificate slice to be ordered downwards the chain.
// SRV CRT -> CA. We need to pull the leaf and issuer certs out of it,
// which should always be the first two certificates.
// If there's no OCSP server listed in the leaf cert, there's nothing to do.
// And if we have only one certificate so far, we need to get the issuer cert.
issuedCert := certificates[0]
if len(issuedCert.OCSPServer) == 0 {
return nil, nil, errors.New("no OCSP server specified in cert")
}
if len(certificates) == 1 {
// TODO: build fallback. If this fails, check the remaining array entries.
if len(issuedCert.IssuingCertificateURL) == 0 {
return nil, nil, errors.New("no issuing certificate URL")
}
resp, errC := c.core.HTTPClient.Get(issuedCert.IssuingCertificateURL[0])
if errC != nil {
return nil, nil, errC
}
defer resp.Body.Close()
issuerBytes, errC := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxBodySize))
if errC != nil {
return nil, nil, errC
}
issuerCert, errC := x509.ParseCertificate(issuerBytes)
if errC != nil {
return nil, nil, errC
}
// Insert it into the slice on position 0
// We want it ordered right SRV CRT -> CA
certificates = append(certificates, issuerCert)
}
issuerCert := certificates[1]
// Finally kick off the OCSP request.
ocspReq, err := ocsp.CreateRequest(issuedCert, issuerCert, nil)
if err != nil {
return nil, nil, err
}
resp, err := c.core.HTTPClient.Post(issuedCert.OCSPServer[0], "application/ocsp-request", bytes.NewReader(ocspReq))
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
ocspResBytes, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxBodySize))
if err != nil {
return nil, nil, err
}
ocspRes, err := ocsp.ParseResponse(ocspResBytes, issuerCert)
if err != nil {
return nil, nil, err
}
return ocspResBytes, ocspRes, nil
}
// Get attempts to fetch the certificate at the supplied URL.
// The URL is the same as what would normally be supplied at the Resource's CertURL.
//
// The returned Resource will not have the PrivateKey and CSR fields populated as these will not be available.
//
// If bundle is true, the Certificate field in the returned Resource includes the issuer certificate.
func (c *Certifier) Get(url string, bundle bool) (*Resource, error) {
cert, issuer, err := c.core.Certificates.Get(url, bundle)
if err != nil {
return nil, err
}
// Parse the returned cert bundle so that we can grab the domain from the common name.
x509Certs, err := certcrypto.ParsePEMBundle(cert)
if err != nil {
return nil, err
}
return &Resource{
Domain: x509Certs[0].Subject.CommonName,
Certificate: cert,
IssuerCertificate: issuer,
CertURL: url,
CertStableURL: url,
}, nil
}
func hasPreferredChain(issuer []byte, preferredChain string) (bool, error) {
certs, err := certcrypto.ParsePEMBundle(issuer)
if err != nil {
return false, err
}
topCert := certs[len(certs)-1]
if topCert.Issuer.CommonName == preferredChain {
return true, nil
}
return false, nil
}
func checkOrderStatus(order acme.ExtendedOrder) (bool, error) {
switch order.Status {
case acme.StatusValid:
return true, nil
case acme.StatusInvalid:
return false, order.Error
default:
return false, nil
}
}
// https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.4
// The domain name MUST be encoded in the form in which it would appear in a certificate.
// That is, it MUST be encoded according to the rules in Section 7 of [RFC5280].
//
// 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 {
log.Infof("skip domain %q: unable to sanitize (punnycode): %v", domain, err)
} else {
sanitizedDomains = append(sanitizedDomains, sanitizedDomain)
}
}
return sanitizedDomains
}

View file

@ -1,398 +0,0 @@
package certificate
import (
"crypto/rand"
"crypto/rsa"
"encoding/pem"
"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/certcrypto"
"github.com/go-acme/lego/v4/platform/tester"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const certResponseNoBundleMock = `-----BEGIN CERTIFICATE-----
MIIDEDCCAfigAwIBAgIHPhckqW5fPDANBgkqhkiG9w0BAQsFADAoMSYwJAYDVQQD
Ex1QZWJibGUgSW50ZXJtZWRpYXRlIENBIDM5NWU2MTAeFw0xODExMDcxNzQ2NTZa
Fw0yMzExMDcxNzQ2NTZaMBMxETAPBgNVBAMTCGFjbWUud3RmMIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwtLNKvZXD20XPUQCWYSK9rUSKxD9Eb0c9fag
bxOxOkLRTgL8LH6yln+bxc3MrHDou4PpDUdeo2CyOQu3CKsTS5mrH3NXYHu0H7p5
y3riOJTHnfkGKLT9LciGz7GkXd62nvNP57bOf5Sk4P2M+Qbxd0hPTSfu52740LSy
144cnxe2P1aDYehrEp6nYCESuyD/CtUHTo0qwJmzIy163Sp3rSs15BuCPyhySnE3
BJ8Ggv+qC6D5I1932DfSqyQJ79iq/HRm0Fn84am3KwvRlUfWxabmsUGARXoqCgnE
zcbJVOZKewv0zlQJpfac+b+Imj6Lvt1TGjIz2mVyefYgLx8gwwIDAQABo1QwUjAO
BgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwG
A1UdEwEB/wQCMAAwEwYDVR0RBAwwCoIIYWNtZS53dGYwDQYJKoZIhvcNAQELBQAD
ggEBABB/0iYhmfPSQot5RaeeovQnsqYjI5ryQK2cwzW6qcTJfv8N6+p6XkqF1+W4
jXZjrQP8MvgO9KNWlvx12vhINE6wubk88L+2piAi5uS2QejmZbXpyYB9s+oPqlk9
IDvfdlVYOqvYAhSx7ggGi+j73mjZVtjAavP6dKuu475ZCeq+NIC15RpbbikWKtYE
HBJ7BW8XQKx67iHGx8ygHTDLbREL80Bck3oUm7wIYGMoNijD6RBl25p4gYl9dzOd
TqGl5hW/1P5hMbgEzHbr4O3BfWqU2g7tV36TASy3jbC3ONFRNNYrpEZ1AL3+cUri
OPPkKtAKAbQkKbUIfsHpBZjKZMU=
-----END CERTIFICATE-----
`
const certResponseMock = `-----BEGIN CERTIFICATE-----
MIIDEDCCAfigAwIBAgIHPhckqW5fPDANBgkqhkiG9w0BAQsFADAoMSYwJAYDVQQD
Ex1QZWJibGUgSW50ZXJtZWRpYXRlIENBIDM5NWU2MTAeFw0xODExMDcxNzQ2NTZa
Fw0yMzExMDcxNzQ2NTZaMBMxETAPBgNVBAMTCGFjbWUud3RmMIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwtLNKvZXD20XPUQCWYSK9rUSKxD9Eb0c9fag
bxOxOkLRTgL8LH6yln+bxc3MrHDou4PpDUdeo2CyOQu3CKsTS5mrH3NXYHu0H7p5
y3riOJTHnfkGKLT9LciGz7GkXd62nvNP57bOf5Sk4P2M+Qbxd0hPTSfu52740LSy
144cnxe2P1aDYehrEp6nYCESuyD/CtUHTo0qwJmzIy163Sp3rSs15BuCPyhySnE3
BJ8Ggv+qC6D5I1932DfSqyQJ79iq/HRm0Fn84am3KwvRlUfWxabmsUGARXoqCgnE
zcbJVOZKewv0zlQJpfac+b+Imj6Lvt1TGjIz2mVyefYgLx8gwwIDAQABo1QwUjAO
BgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwG
A1UdEwEB/wQCMAAwEwYDVR0RBAwwCoIIYWNtZS53dGYwDQYJKoZIhvcNAQELBQAD
ggEBABB/0iYhmfPSQot5RaeeovQnsqYjI5ryQK2cwzW6qcTJfv8N6+p6XkqF1+W4
jXZjrQP8MvgO9KNWlvx12vhINE6wubk88L+2piAi5uS2QejmZbXpyYB9s+oPqlk9
IDvfdlVYOqvYAhSx7ggGi+j73mjZVtjAavP6dKuu475ZCeq+NIC15RpbbikWKtYE
HBJ7BW8XQKx67iHGx8ygHTDLbREL80Bck3oUm7wIYGMoNijD6RBl25p4gYl9dzOd
TqGl5hW/1P5hMbgEzHbr4O3BfWqU2g7tV36TASy3jbC3ONFRNNYrpEZ1AL3+cUri
OPPkKtAKAbQkKbUIfsHpBZjKZMU=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDDDCCAfSgAwIBAgIIOV5hkYJx0JwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
AxMVUGViYmxlIFJvb3QgQ0EgNTBmZmJkMB4XDTE4MTEwNzE3NDY0N1oXDTQ4MTEw
NzE3NDY0N1owKDEmMCQGA1UEAxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSAzOTVl
NjEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCacwXN4LmyRTgYS8TT
SZYgz758npHiPTBDKgeN5WVmkkwW0TuN4W2zXhEmcM82uxOEjWS2drvK0+iJKneh
0fQR8ZF35dIYFe8WXTg3kEmqcizSgh4LxlOntsXvatfX/6GU/ADo3xAFoBKCijen
SRBIY65yq5m00cWx3RMIcQq1B0X8nJS0O1P7MYE/Vvidz5St/36RXVu1oWLeS5Fx
HAezW0lqxEUzvC+uLTFWC6f/CilzmI7SsPAkZBk7dO5Qs0d7m/zWF588vlGS+0pt
D1on+lU85Ma2zuAd0qmB6LY66N8pEKKtMk93wF/o4Z5i58ahbwNvTKAzz4JSRWSu
mB9LAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcD
AQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEA
upU0DjzvIvoCOYKbq1RRN7rPdYad39mfjxgkeV0iOF5JoIdO6y1C7XAm9lT69Wjm
iUPvnCTMDYft40N2SvmXuuMaPOm4zjNwn4K33jw5XBnpwxC7By/Y0oV+Sl10fBsd
QqXC6H7LcSGkv+4eJbgY33P4uH5ZAy+2TkHUuZDkpufkAshzBust7nDAjfv3AIuQ
wlPoyZfI11eqyiOqRzOq+B5dIBr1JzKnEzSL6n0JLNQiPO7iN03rud/wYD3gbmcv
rzFL1KZfz+HZdnFwFW2T2gVW8L3ii1l9AJDuKzlvjUH3p6bgihVq02sjT8mx+GM2
7R4IbHGnj0BJA2vMYC4hSw==
-----END CERTIFICATE-----
`
const issuerMock = `-----BEGIN CERTIFICATE-----
MIIDDDCCAfSgAwIBAgIIOV5hkYJx0JwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
AxMVUGViYmxlIFJvb3QgQ0EgNTBmZmJkMB4XDTE4MTEwNzE3NDY0N1oXDTQ4MTEw
NzE3NDY0N1owKDEmMCQGA1UEAxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSAzOTVl
NjEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCacwXN4LmyRTgYS8TT
SZYgz758npHiPTBDKgeN5WVmkkwW0TuN4W2zXhEmcM82uxOEjWS2drvK0+iJKneh
0fQR8ZF35dIYFe8WXTg3kEmqcizSgh4LxlOntsXvatfX/6GU/ADo3xAFoBKCijen
SRBIY65yq5m00cWx3RMIcQq1B0X8nJS0O1P7MYE/Vvidz5St/36RXVu1oWLeS5Fx
HAezW0lqxEUzvC+uLTFWC6f/CilzmI7SsPAkZBk7dO5Qs0d7m/zWF588vlGS+0pt
D1on+lU85Ma2zuAd0qmB6LY66N8pEKKtMk93wF/o4Z5i58ahbwNvTKAzz4JSRWSu
mB9LAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcD
AQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEA
upU0DjzvIvoCOYKbq1RRN7rPdYad39mfjxgkeV0iOF5JoIdO6y1C7XAm9lT69Wjm
iUPvnCTMDYft40N2SvmXuuMaPOm4zjNwn4K33jw5XBnpwxC7By/Y0oV+Sl10fBsd
QqXC6H7LcSGkv+4eJbgY33P4uH5ZAy+2TkHUuZDkpufkAshzBust7nDAjfv3AIuQ
wlPoyZfI11eqyiOqRzOq+B5dIBr1JzKnEzSL6n0JLNQiPO7iN03rud/wYD3gbmcv
rzFL1KZfz+HZdnFwFW2T2gVW8L3ii1l9AJDuKzlvjUH3p6bgihVq02sjT8mx+GM2
7R4IbHGnj0BJA2vMYC4hSw==
-----END CERTIFICATE-----
`
const certResponseMock2 = `
-----BEGIN CERTIFICATE-----
MIIFUzCCBDugAwIBAgISA/z9btaZCSo/qlVwmJrHpoyPMA0GCSqGSIb3DQEBCwUA
MEoxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MSMwIQYDVQQD
ExpMZXQncyBFbmNyeXB0IEF1dGhvcml0eSBYMzAeFw0yMDA3MjUwNjUxNDRaFw0y
MDEwMjMwNjUxNDRaMBgxFjAUBgNVBAMTDW5hdHVyZS5nbG9iYWwwggEiMA0GCSqG
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDN/PF8lWub3i+lO3CLl/HJAM86pQH9hWej
Whci1PPNzKyEByJq2psNLCO1W1mXK3ClWSyifptCf7+AAFAOoBojPMwjaKMziw1M
BxAQiX8MzZLv4Hr4Uk08cQX31QHiEpOv4pMHqB0UpodTYY10dZnDdyJHaGKzxfJh
nQPYIVto+UegcVu9iZIDow7ugoT2Gh8nB8jOAc4wtBgmylgeAFmYR6QZ4PYSYFh0
DLZGGB1WuU/4YC5OciwTDv5EiqP3KM3NdkmGhPY0A3jcTrjN+HhcE4pYBtG1wHi8
PEuqqKyCLa3AjHq4WrZyCCkCMXPbIDS1Qt7botDmUZr/26xJZnl5AgMBAAGjggJj
MIICXzAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUF
BwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFFm72Cv7LnjVhcLqUujrykUr70lF
MB8GA1UdIwQYMBaAFKhKamMEfd265tE5t6ZFZe/zqOyhMG8GCCsGAQUFBwEBBGMw
YTAuBggrBgEFBQcwAYYiaHR0cDovL29jc3AuaW50LXgzLmxldHNlbmNyeXB0Lm9y
ZzAvBggrBgEFBQcwAoYjaHR0cDovL2NlcnQuaW50LXgzLmxldHNlbmNyeXB0Lm9y
Zy8wGAYDVR0RBBEwD4INbmF0dXJlLmdsb2JhbDBMBgNVHSAERTBDMAgGBmeBDAEC
ATA3BgsrBgEEAYLfEwEBATAoMCYGCCsGAQUFBwIBFhpodHRwOi8vY3BzLmxldHNl
bmNyeXB0Lm9yZzCCAQUGCisGAQQB1nkCBAIEgfYEgfMA8QB3ALIeBcyLos2KIE6H
ZvkruYolIGdr2vpw57JJUy3vi5BeAAABc4T006IAAAQDAEgwRgIhAPEEvCEMkekD
8XLDaxHPnJ85UZL72JqGgNK+7I/NdFNuAiEA5D78b4V1YsD8wvWz/sk6Ks8VgjED
eKGl/TyXwKEpzEIAdgBvU3asMfAxGdiZAKRRFf93FRwR2QLBACkGjbIImjfZEwAA
AXOE9NPrAAAEAwBHMEUCIAu4YFfGZIN/P+0eRG0krSddHKCSf6rqr6aVqUWkJY3F
AiEAz0HkTe0alED1gW9nEAJ1qqK1MLMjRM8SsUv9Is86+CwwDQYJKoZIhvcNAQEL
BQADggEBAGriSVi9YuBnm50w84gjlinmeGdvxgugblIoEqKoXd3d5/zx0DvW9Tm6
YGfXsvAJUSCag7dZ/s/PEu23jKNdFoaBmDaUHHKnUwbWWF7/ptYZ+YuDVGOJo8PL
CULNfUMon20rPU9smzW4BFDBZ6KmX/r4Q8cQ7FLOqKdcng0yMcqIfq4cBxEvd0uQ
pHR3AwCjAIGpV6Q9WHHiHx+SEd/Xc18Z5pXa9m3Rz4i6Mfv+AYLtnsZDxcH81cVM
7rYp80vhXM9tFd4wyrqLuaVZgYD1ylxTYpTI7sijIq4Sl984f3IPA/olN+zK6E8d
EbiufIcKeju/aSellDzzBabEo80YT4o=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/
MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT
DkRTVCBSb290IENBIFgzMB4XDTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVow
PzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QgQ28uMRcwFQYDVQQD
Ew5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
AN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmTrE4O
rz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEq
OLl5CjH9UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9b
xiqKqy69cK3FCxolkHRyxXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw
7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40dutolucbY38EVAjqr2m7xPi71XAicPNaD
aeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV
HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQMA0GCSqG
SIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69
ikugdB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXr
AvHRAosZy5Q6XkjEGB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZz
R8srzJmwN0jP41ZL9c8PDHIyh8bwRLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5
JDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubSfZGL+T0yjWW06XyxV3bqxbYo
Ob8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ
-----END CERTIFICATE-----
`
const issuerMock2 = `-----BEGIN CERTIFICATE-----
MIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/
MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT
DkRTVCBSb290IENBIFgzMB4XDTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVow
PzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QgQ28uMRcwFQYDVQQD
Ew5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
AN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmTrE4O
rz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEq
OLl5CjH9UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9b
xiqKqy69cK3FCxolkHRyxXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw
7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40dutolucbY38EVAjqr2m7xPi71XAicPNaD
aeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV
HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQMA0GCSqG
SIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69
ikugdB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXr
AvHRAosZy5Q6XkjEGB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZz
R8srzJmwN0jP41ZL9c8PDHIyh8bwRLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5
JDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubSfZGL+T0yjWW06XyxV3bqxbYo
Ob8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ
-----END CERTIFICATE-----
`
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
}
})
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)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
order := acme.ExtendedOrder{
Order: acme.Order{
Status: acme.StatusValid,
Certificate: apiURL + "/certificate",
},
}
certRes := &Resource{}
valid, err := certifier.checkResponse(order, certRes, true, "")
require.NoError(t, err)
assert.True(t, valid)
assert.NotNil(t, certRes)
assert.Equal(t, "", certRes.Domain)
assert.Contains(t, certRes.CertStableURL, "/certificate")
assert.Contains(t, certRes.CertURL, "/certificate")
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_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
}
})
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)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
order := acme.ExtendedOrder{
Order: acme.Order{
Status: acme.StatusValid,
Certificate: apiURL + "/certificate",
},
}
certRes := &Resource{}
valid, err := certifier.checkResponse(order, certRes, true, "")
require.NoError(t, err)
assert.True(t, valid)
assert.NotNil(t, certRes)
assert.Equal(t, "", certRes.Domain)
assert.Contains(t, certRes.CertStableURL, "/certificate")
assert.Contains(t, certRes.CertURL, "/certificate")
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_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
}
})
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)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
order := acme.ExtendedOrder{
Order: acme.Order{
Status: acme.StatusValid,
Certificate: apiURL + "/certificate",
},
}
certRes := &Resource{}
valid, err := certifier.checkResponse(order, certRes, false, "")
require.NoError(t, err)
assert.True(t, valid)
assert.NotNil(t, certRes)
assert.Equal(t, "", certRes.Domain)
assert.Contains(t, certRes.CertStableURL, "/certificate")
assert.Contains(t, certRes.CertURL, "/certificate")
assert.Nil(t, certRes.CSR)
assert.Nil(t, certRes.PrivateKey)
assert.Equal(t, certResponseNoBundleMock, string(certRes.Certificate), "Certificate")
assert.Equal(t, issuerMock, string(certRes.IssuerCertificate), "IssuerCertificate")
}
func Test_checkResponse_alternate(t *testing.T) {
mux, apiURL := tester.SetupFakeAPI(t)
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
}
})
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)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
order := acme.ExtendedOrder{
Order: acme.Order{
Status: acme.StatusValid,
Certificate: apiURL + "/certificate",
},
}
certRes := &Resource{
Domain: "example.com",
}
valid, err := certifier.checkResponse(order, certRes, true, "DST Root CA X3")
require.NoError(t, err)
assert.True(t, valid)
assert.NotNil(t, certRes)
assert.Equal(t, "example.com", certRes.Domain)
assert.Contains(t, certRes.CertStableURL, "/certificate/1")
assert.Contains(t, certRes.CertURL, "/certificate/1")
assert.Nil(t, certRes.CSR)
assert.Nil(t, certRes.PrivateKey)
assert.Equal(t, certResponseMock2, string(certRes.Certificate), "Certificate")
assert.Equal(t, issuerMock2, string(certRes.IssuerCertificate), "IssuerCertificate")
}
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
}
})
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)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
certRes, err := certifier.Get(apiURL+"/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.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")
}
type resolverMock struct {
error error
}
func (r *resolverMock) Solve(_ []acme.Authorization) error {
return r.error
}

View file

@ -1,30 +0,0 @@
package certificate
import (
"bytes"
"fmt"
"sort"
)
// obtainError is returned when there are specific errors available per domain.
type obtainError map[string]error
func (e obtainError) Error() string {
buffer := bytes.NewBufferString("error: one or more domains had a problem:\n")
var domains []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()
}
type domainError struct {
Domain string
Error error
}

View file

@ -1,204 +0,0 @@
package certificate
import (
"crypto"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"math/rand"
"strings"
"time"
"github.com/go-acme/lego/v4/acme"
)
// RenewalInfoRequest contains the necessary renewal information.
type RenewalInfoRequest struct {
Cert *x509.Certificate
Issuer *x509.Certificate
// HashName must be the string representation of a crypto.Hash constant in the golang.org/x/crypto package (e.g. "SHA-256").
// The correct value depends on the algorithm expected by the ACME server's ARI implementation.
HashName string
}
// RenewalInfoResponse is a wrapper around acme.RenewalInfoResponse that provides a method for determining when to renew a certificate.
type RenewalInfoResponse struct {
acme.RenewalInfoResponse
}
// 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.
//
// - (4.1-11. Getting Renewal Information) https://datatracker.ietf.org/doc/draft-ietf-acme-ari/
func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.Duration) *time.Time {
// Explicitly convert all times to UTC.
now = now.UTC()
start := r.SuggestedWindow.Start.UTC()
end := r.SuggestedWindow.End.UTC()
// Select a uniform random time within the suggested window.
window := end.Sub(start)
randomDuration := time.Duration(rand.Int63n(int64(window)))
rt := start.Add(randomDuration)
// If the selected time is in the past, attempt renewal immediately.
if rt.Before(now) {
return &now
}
// Otherwise, if the client can schedule itself to attempt renewal at exactly the selected time, do so.
willingToSleepUntil := now.Add(willingToSleep)
if willingToSleepUntil.After(rt) || willingToSleepUntil.Equal(rt) {
return &rt
}
// TODO: Otherwise, if the selected time is before the next time that the client would wake up normally, attempt renewal immediately.
// Otherwise, sleep until the next normal wake time, re-check ARI, and return to Step 1.
return nil
}
// GetRenewalInfo sends a request to the ACME server's renewalInfo endpoint to obtain a suggested renewal window.
// The caller MUST provide the certificate and issuer certificate for the certificate they wish to renew.
// The caller should attempt to renew the certificate at the time indicated by the ShouldRenewAt method of the returned RenewalInfoResponse object.
//
// 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
func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse, error) {
certID, err := makeCertID(req.Cert, req.Issuer, req.HashName)
if err != nil {
return nil, fmt.Errorf("error making certID: %w", err)
}
resp, err := c.core.Certificates.GetRenewalInfo(certID)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var info RenewalInfoResponse
err = json.NewDecoder(resp.Body).Decode(&info)
if err != nil {
return nil, err
}
return &info, nil
}
// UpdateRenewalInfo sends an update to the ACME server's renewal info endpoint to indicate that the client has successfully replaced a certificate.
// A certificate is considered replaced when its revocation would not disrupt any ongoing services,
// for instance because it has been renewed and the new certificate is in use, or because it is no longer in use.
//
// 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
func (c *Certifier) UpdateRenewalInfo(req RenewalInfoRequest) error {
certID, err := makeCertID(req.Cert, req.Issuer, req.HashName)
if err != nil {
return fmt.Errorf("error making certID: %w", err)
}
_, err = c.core.Certificates.UpdateRenewalInfo(acme.RenewalInfoUpdateRequest{
CertID: certID,
Replaced: true,
})
if err != nil {
return err
}
return nil
}
// makeCertID returns a base64url-encoded string that uniquely identifies a certificate to endpoints
// that implement the draft-ietf-acme-ari specification: https://datatracker.ietf.org/doc/draft-ietf-acme-ari.
// hashName must be the string representation of a crypto.Hash constant in the golang.org/x/crypto package.
// Supported hash functions are SHA-1, SHA-256, SHA-384, and SHA-512.
func makeCertID(leaf, issuer *x509.Certificate, hashName string) (string, error) {
if leaf == nil {
return "", fmt.Errorf("leaf certificate is nil")
}
if issuer == nil {
return "", fmt.Errorf("issuer certificate is nil")
}
var hashFunc crypto.Hash
var oid asn1.ObjectIdentifier
switch hashName {
// The following correlation of hashFunc to OID is copied from a private mapping in golang.org/x/crypto/ocsp:
// https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.8.0:ocsp/ocsp.go;l=156
case crypto.SHA1.String():
hashFunc = crypto.SHA1
oid = asn1.ObjectIdentifier([]int{1, 3, 14, 3, 2, 26})
case crypto.SHA256.String():
hashFunc = crypto.SHA256
oid = asn1.ObjectIdentifier([]int{2, 16, 840, 1, 101, 3, 4, 2, 1})
case crypto.SHA384.String():
hashFunc = crypto.SHA384
oid = asn1.ObjectIdentifier([]int{2, 16, 840, 1, 101, 3, 4, 2, 2})
case crypto.SHA512.String():
hashFunc = crypto.SHA512
oid = asn1.ObjectIdentifier([]int{2, 16, 840, 1, 101, 3, 4, 2, 3})
default:
return "", fmt.Errorf("hashName %q is not supported by this package", hashName)
}
if !hashFunc.Available() {
// This should never happen.
return "", fmt.Errorf("hash function %q is not available on your platform", hashFunc)
}
var spki struct {
Algorithm pkix.AlgorithmIdentifier
PublicKey asn1.BitString
}
_, err := asn1.Unmarshal(issuer.RawSubjectPublicKeyInfo, &spki)
if err != nil {
return "", err
}
h := hashFunc.New()
h.Write(spki.PublicKey.RightAlign())
issuerKeyHash := h.Sum(nil)
h.Reset()
h.Write(issuer.RawSubject)
issuerNameHash := h.Sum(nil)
type certID struct {
HashAlgorithm pkix.AlgorithmIdentifier
IssuerNameHash []byte
IssuerKeyHash []byte
SerialNumber *big.Int
}
// DER-encode the CertID ASN.1 sequence [RFC6960].
certIDBytes, err := asn1.Marshal(certID{
HashAlgorithm: pkix.AlgorithmIdentifier{
Algorithm: oid,
},
IssuerNameHash: issuerNameHash,
IssuerKeyHash: issuerKeyHash,
SerialNumber: leaf.SerialNumber,
})
if err != nil {
return "", err
}
// base64url-encode [RFC4648] the bytes of the DER-encoded CertID ASN.1 sequence [RFC6960].
encodedBytes := base64.URLEncoding.EncodeToString(certIDBytes)
// Any trailing '=' characters MUST be stripped.
return strings.TrimRight(encodedBytes, "="), nil
}

View file

@ -1,356 +0,0 @@
package certificate
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"io"
"net/http"
"testing"
"time"
"github.com/go-acme/lego/v4/acme"
"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-jose/go-jose/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
ariLeafPEM = `-----BEGIN CERTIFICATE-----
MIIDMDCCAhigAwIBAgIIPqNFaGVEHxwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
AxMVbWluaWNhIHJvb3QgY2EgM2ExMzU2MB4XDTIyMDMxNzE3NTEwOVoXDTI0MDQx
NjE3NTEwOVowFjEUMBIGA1UEAxMLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQCgm9K/c+il2Pf0f8qhgxn9SKqXq88cOm9ov9AVRbPA
OWAAewqX2yUAwI4LZBGEgzGzTATkiXfoJ3cN3k39cH6tBbb3iSPuEn7OZpIk9D+e
3Q9/hX+N/jlWkaTB/FNA+7aE5IVWhmdczYilXa10V9r+RcvACJt0gsipBZVJ4jfJ
HnWJJGRZzzxqG/xkQmpXxZO7nOPFc8SxYKWdfcgp+rjR2ogYhSz7BfKoVakGPbpX
vZOuT9z4kkHra/WjwlkQhtHoTXdAxH3qC2UjMzO57Tx+otj0CxAv9O7CTJXISywB
vEVcmTSZkHS3eZtvvIwPx7I30ITRkYk/tLl1MbyB3SiZAgMBAAGjeDB2MA4GA1Ud
DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0T
AQH/BAIwADAfBgNVHSMEGDAWgBQ4zzDRUaXHVKqlSTWkULGU4zGZpTAWBgNVHREE
DzANggtleGFtcGxlLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAx0aYvmCk7JYGNEXe
+hrOfKawkHYzWvA92cI/Oi6h+oSdHZ2UKzwFNf37cVKZ37FCrrv5pFP/xhhHvrNV
EnOx4IaF7OrnaTu5miZiUWuvRQP7ZGmGNFYbLTEF6/dj+WqyYdVaWzxRqHFu1ptC
TXysJCeyiGnR+KOOjOOQ9ZlO5JUK3OE4hagPLfaIpDDy6RXQt3ss0iNLuB1+IOtp
1URpvffLZQ8xPsEgOZyPWOcabTwJrtqBwily+lwPFn2mChUx846LwQfxtsXU/lJg
HX2RteNJx7YYNeX3Uf960mgo5an6vE8QNAsIoNHYrGyEmXDhTRe9mCHyiW2S7fZq
o9q12g==
-----END CERTIFICATE-----`
ariIssuerPEM = `-----BEGIN CERTIFICATE-----
MIIDSzCCAjOgAwIBAgIIOhNWtJ7Igr0wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
AxMVbWluaWNhIHJvb3QgY2EgM2ExMzU2MCAXDTIyMDMxNzE3NTEwOVoYDzIxMjIw
MzE3MTc1MTA5WjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAzYTEzNTYwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDc3P6cxcCZ7FQOQrYuigReSa8T
IOPNKmlmX9OrTkPwjThiMNEETYKO1ea99yXPK36LUHC6OLmZ9jVQW2Ny1qwQCOy6
TrquhnwKgtkBMDAZBLySSEXYdKL3r0jA4sflW130/OLwhstU/yv0J8+pj7eSVOR3
zJBnYd1AqnXHRSwQm299KXgqema7uwsa8cgjrXsBzAhrwrvYlVhpWFSv3lQRDFQg
c5Z/ZDV9i26qiaJsCCmdisJZWN7N2luUgxdRqzZ4Cr2Xoilg3T+hkb2y/d6ttsPA
kaSA+pq3q6Qa7/qfGdT5WuUkcHpvKNRWqnwT9rCYlmG00r3hGgc42D/z1VvfAgMB
AAGjgYYwgYMwDgYDVR0PAQH/BAQDAgKEMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggr
BgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBQ4zzDRUaXHVKql
STWkULGU4zGZpTAfBgNVHSMEGDAWgBQ4zzDRUaXHVKqlSTWkULGU4zGZpTANBgkq
hkiG9w0BAQsFAAOCAQEArbDHhEjGedjb/YjU80aFTPWOMRjgyfQaPPgyxwX6Dsid
1i2H1x4ud4ntz3sTZZxdQIrOqtlIWTWVCjpStwGxaC+38SdreiTTwy/nikXGa/6W
ZyQRppR3agh/pl5LHVO6GsJz3YHa7wQhEhj3xsRwa9VrRXgHbLGbPOFVRTHPjaPg
Gtsv2PN3f67DsPHF47ASqyOIRpLZPQmZIw6D3isJwfl+8CzvlB1veO0Q3uh08IJc
fspYQXvFBzYa64uKxNAJMi4Pby8cf4r36Wnb7cL4ho3fOHgAltxdW8jgibRzqZpQ
QKyxn2jX7kxeUDt0hFDJE8lOrhP73m66eBNzxe//FQ==
-----END CERTIFICATE-----`
ariLeafCertID = "MFswCwYJYIZIAWUDBAIBBCCeWLRusNLb--vmWOkxm34qDjTMWkc3utIhOMoMwKDqbgQg2iiKWySZrD-6c88HMZ6vhIHZPamChLlzGHeZ7pTS8jYCCD6jRWhlRB8c"
)
func Test_makeCertID(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM))
require.NoError(t, err)
actual, err := makeCertID(leaf, issuer, crypto.SHA256.String())
require.NoError(t, err)
assert.Equal(t, ariLeafCertID, actual)
}
func TestCertifier_GetRenewalInfo(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM))
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.WriteHeader(http.StatusOK)
_, wErr := w.Write([]byte(`{
"suggestedWindow": {
"start": "2020-03-17T17:51:09Z",
"end": "2020-03-17T18:21:09Z"
},
"explanationUrl": "https://aricapable.ca/docs/renewal-advice/"
}
}`))
require.NoError(t, wErr)
})
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)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
ri, err := certifier.GetRenewalInfo(RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()})
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/docs/renewal-advice/", ri.ExplanationURL)
}
func TestCertifier_GetRenewalInfo_errors(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM))
require.NoError(t, err)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
testCases := []struct {
desc string
httpClient *http.Client
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, issuer, crypto.SHA256.String()},
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, issuer, crypto.SHA256.String()},
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)
},
},
{
desc: "Issuer certificate is nil",
httpClient: http.DefaultClient,
request: RenewalInfoRequest{leaf, nil, crypto.SHA256.String()},
handler: func(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
mux, apiURL := tester.SetupFakeAPI(t)
mux.HandleFunc("/renewalInfo/"+ariLeafCertID, test.handler)
core, err := api.New(test.httpClient, "lego-test", apiURL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
response, err := certifier.GetRenewalInfo(test.request)
require.Error(t, err)
assert.Nil(t, response)
})
}
}
func TestCertifier_UpdateRenewalInfo(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM))
require.NoError(t, err)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
// Test with a fake API.
mux, apiURL := tester.SetupFakeAPI(t)
mux.HandleFunc("/renewalInfo", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
body, rsbErr := readSignedBody(r, key)
if rsbErr != nil {
http.Error(w, rsbErr.Error(), http.StatusBadRequest)
return
}
var req acme.RenewalInfoUpdateRequest
err = json.Unmarshal(body, &req)
assert.NoError(t, err)
assert.True(t, req.Replaced)
assert.Equal(t, ariLeafCertID, req.CertID)
w.WriteHeader(http.StatusOK)
})
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
err = certifier.UpdateRenewalInfo(RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()})
require.NoError(t, err)
}
func TestCertifier_UpdateRenewalInfo_errors(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM))
require.NoError(t, err)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
testCases := []struct {
desc string
request RenewalInfoRequest
}{
{
desc: "API error",
request: RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()},
},
{
desc: "Certificate is nil",
request: RenewalInfoRequest{nil, issuer, crypto.SHA256.String()},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
mux, apiURL := tester.SetupFakeAPI(t)
// Always returns an error.
mux.HandleFunc("/renewalInfo", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
})
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
err = certifier.UpdateRenewalInfo(test.request)
require.Error(t, err)
})
}
}
func TestRenewalInfoResponse_ShouldRenew(t *testing.T) {
now := time.Now().UTC()
t.Run("Window is in the past", func(t *testing.T) {
ri := RenewalInfoResponse{
acme.RenewalInfoResponse{
SuggestedWindow: acme.Window{
Start: now.Add(-2 * time.Hour),
End: now.Add(-1 * time.Hour),
},
ExplanationURL: "",
},
}
rt := ri.ShouldRenewAt(now, 0)
require.NotNil(t, rt)
assert.Equal(t, now, *rt)
})
t.Run("Window is in the future", func(t *testing.T) {
ri := RenewalInfoResponse{
acme.RenewalInfoResponse{
SuggestedWindow: acme.Window{
Start: now.Add(1 * time.Hour),
End: now.Add(2 * time.Hour),
},
ExplanationURL: "",
},
}
rt := ri.ShouldRenewAt(now, 0)
assert.Nil(t, rt)
})
t.Run("Window is in the future, but caller is willing to sleep", func(t *testing.T) {
ri := RenewalInfoResponse{
acme.RenewalInfoResponse{
SuggestedWindow: acme.Window{
Start: now.Add(1 * time.Hour),
End: now.Add(2 * time.Hour),
},
ExplanationURL: "",
},
}
rt := ri.ShouldRenewAt(now, 2*time.Hour)
require.NotNil(t, rt)
assert.True(t, rt.Before(now.Add(2*time.Hour)))
})
t.Run("Window is in the future, but caller isn't willing to sleep long enough", func(t *testing.T) {
ri := RenewalInfoResponse{
acme.RenewalInfoResponse{
SuggestedWindow: acme.Window{
Start: now.Add(1 * time.Hour),
End: now.Add(2 * time.Hour),
},
ExplanationURL: "",
},
}
rt := ri.ShouldRenewAt(now, 59*time.Minute)
assert.Nil(t, rt)
})
}
func readSignedBody(r *http.Request, privateKey *rsa.PrivateKey) ([]byte, error) {
reqBody, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
jws, err := jose.ParseSigned(string(reqBody))
if err != nil {
return nil, err
}
body, err := jws.Verify(&jose.JSONWebKey{
Key: privateKey.Public(),
Algorithm: "RSA",
})
if err != nil {
return nil, err
}
return body, nil
}

View file

@ -1,47 +0,0 @@
package challenge
import (
"fmt"
"github.com/go-acme/lego/v4/acme"
)
// Type is a string that identifies a particular challenge type and version of ACME challenge.
type Type string
const (
// HTTP01 is the "http-01" ACME challenge https://www.rfc-editor.org/rfc/rfc8555.html#section-8.3
// Note: ChallengePath returns the URL path to fulfill this challenge.
HTTP01 = Type("http-01")
// DNS01 is the "dns-01" ACME challenge https://www.rfc-editor.org/rfc/rfc8555.html#section-8.4
// Note: GetRecord returns a DNS record which will fulfill this challenge.
DNS01 = Type("dns-01")
// TLSALPN01 is the "tls-alpn-01" ACME challenge https://www.rfc-editor.org/rfc/rfc8737.html
TLSALPN01 = Type("tls-alpn-01")
// NNS01 is the "nns-01" ACME challenge
NNS01 = Type("nns-01")
)
func (t Type) String() string {
return string(t)
}
func FindChallenge(chlgType Type, authz acme.Authorization) (acme.Challenge, error) {
for _, chlg := range authz.Challenges {
if chlg.Type == string(chlgType) {
return chlg, nil
}
}
return acme.Challenge{}, fmt.Errorf("[%s] acme: unable to find challenge %s", GetTargetedDomain(authz), chlgType)
}
func GetTargetedDomain(authz acme.Authorization) string {
if authz.Wildcard {
return "*." + authz.Identifier.Value
}
return authz.Identifier.Value
}

View file

@ -1,20 +0,0 @@
package dns01
import (
"strings"
"github.com/miekg/dns"
)
// Update FQDN with CNAME if any.
func updateDomainWithCName(r *dns.Msg, fqdn string) string {
for _, rr := range r.Answer {
if cn, ok := rr.(*dns.CNAME); ok {
if strings.EqualFold(cn.Hdr.Name, fqdn) {
return cn.Target
}
}
}
return fqdn
}

View file

@ -1,35 +0,0 @@
package dns01
import (
"strings"
"testing"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
)
func Test_updateDomainWithCName_caseInsensitive(t *testing.T) {
qname := "_acme-challenge.uppercase-test.example.com."
cnameTarget := "_acme-challenge.uppercase-test.cname-target.example.com."
msg := &dns.Msg{
MsgHdr: dns.MsgHdr{
Authoritative: true,
},
Answer: []dns.RR{
&dns.CNAME{
Hdr: dns.RR_Header{
Name: strings.ToUpper(qname), // CNAME names are case-insensitive
Rrtype: dns.TypeCNAME,
Class: dns.ClassINET,
Ttl: 3600,
},
Target: cnameTarget,
},
},
}
fqdn := updateDomainWithCName(msg, qname)
assert.Equal(t, cnameTarget, fqdn)
}

View file

@ -1,238 +0,0 @@
package dns01
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"os"
"strconv"
"time"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/wait"
"github.com/miekg/dns"
)
const (
// DefaultPropagationTimeout default propagation timeout.
DefaultPropagationTimeout = 60 * time.Second
// DefaultPollingInterval default polling interval.
DefaultPollingInterval = 2 * time.Second
// DefaultTTL default TTL.
DefaultTTL = 120
)
type ValidateFunc func(core *api.Core, domain string, chlng acme.Challenge) error
type ChallengeOption func(*Challenge) error
// CondOption Conditional challenge option.
func CondOption(condition bool, opt ChallengeOption) ChallengeOption {
if !condition {
// NoOp options
return func(*Challenge) error {
return nil
}
}
return opt
}
// Challenge implements the dns-01 challenge.
type Challenge struct {
core *api.Core
validate ValidateFunc
provider challenge.Provider
preCheck preCheck
dnsTimeout time.Duration
}
func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider, opts ...ChallengeOption) *Challenge {
chlg := &Challenge{
core: core,
validate: validate,
provider: provider,
preCheck: newPreCheck(),
dnsTimeout: 10 * time.Second,
}
for _, opt := range opts {
err := opt(chlg)
if err != nil {
log.Infof("challenge option error: %v", err)
}
}
return chlg
}
// PreSolve just submits the txt record to the dns provider.
// It does not validate record propagation, or do anything at all with the acme server.
func (c *Challenge) PreSolve(authz acme.Authorization) error {
domain := challenge.GetTargetedDomain(authz)
log.Infof("[%s] acme: Preparing to solve DNS-01", domain)
chlng, err := challenge.FindChallenge(challenge.DNS01, authz)
if err != nil {
return err
}
if c.provider == nil {
return fmt.Errorf("[%s] acme: no DNS Provider configured", domain)
}
// Generate the Key Authorization for the challenge
keyAuth, err := c.core.GetKeyAuthorization(chlng.Token)
if err != nil {
return err
}
err = c.provider.Present(authz.Identifier.Value, chlng.Token, keyAuth)
if err != nil {
return fmt.Errorf("[%s] acme: error presenting token: %w", domain, err)
}
return nil
}
func (c *Challenge) Solve(authz acme.Authorization) error {
domain := challenge.GetTargetedDomain(authz)
log.Infof("[%s] acme: Trying to solve DNS-01", domain)
chlng, err := challenge.FindChallenge(challenge.DNS01, authz)
if err != nil {
return err
}
// Generate the Key Authorization for the challenge
keyAuth, err := c.core.GetKeyAuthorization(chlng.Token)
if err != nil {
return err
}
info := GetChallengeInfo(authz.Identifier.Value, keyAuth)
var timeout, interval time.Duration
switch provider := c.provider.(type) {
case challenge.ProviderTimeout:
timeout, interval = provider.Timeout()
default:
timeout, interval = DefaultPropagationTimeout, DefaultPollingInterval
}
log.Infof("[%s] acme: Checking DNS record propagation using %+v", domain, recursiveNameservers)
time.Sleep(interval)
err = wait.For("propagation", timeout, interval, func() (bool, error) {
stop, errP := c.preCheck.call(domain, info.EffectiveFQDN, info.Value)
if !stop || errP != nil {
log.Infof("[%s] acme: Waiting for DNS record propagation.", domain)
}
return stop, errP
})
if err != nil {
return err
}
chlng.KeyAuthorization = keyAuth
return c.validate(c.core, domain, chlng)
}
// CleanUp cleans the challenge.
func (c *Challenge) CleanUp(authz acme.Authorization) error {
log.Infof("[%s] acme: Cleaning DNS-01 challenge", challenge.GetTargetedDomain(authz))
chlng, err := challenge.FindChallenge(challenge.DNS01, authz)
if err != nil {
return err
}
keyAuth, err := c.core.GetKeyAuthorization(chlng.Token)
if err != nil {
return err
}
return c.provider.CleanUp(authz.Identifier.Value, chlng.Token, keyAuth)
}
func (c *Challenge) Sequential() (bool, time.Duration) {
if p, ok := c.provider.(sequential); ok {
return ok, p.Sequential()
}
return false, 0
}
type sequential interface {
Sequential() time.Duration
}
// 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)
return info.EffectiveFQDN, info.Value
}
// ChallengeInfo contains the information use to create the TXT record.
type ChallengeInfo struct {
// FQDN is the full-qualified challenge domain (i.e. `_acme-challenge.[domain].`)
FQDN string
// EffectiveFQDN contains the resulting FQDN after the CNAMEs resolutions.
EffectiveFQDN string
// Value contains the value for the TXT record.
Value string
}
// GetChallengeInfo returns information used to create a DNS record which will fulfill the `dns-01` challenge.
func GetChallengeInfo(domain, keyAuth string) ChallengeInfo {
keyAuthShaBytes := sha256.Sum256([]byte(keyAuth))
// base64URL encoding without padding
value := base64.RawURLEncoding.EncodeToString(keyAuthShaBytes[:sha256.Size])
ok, _ := strconv.ParseBool(os.Getenv("LEGO_DISABLE_CNAME_SUPPORT"))
return ChallengeInfo{
Value: value,
FQDN: getChallengeFQDN(domain, false),
EffectiveFQDN: getChallengeFQDN(domain, !ok),
}
}
func getChallengeFQDN(domain string, followCNAME bool) string {
fqdn := fmt.Sprintf("_acme-challenge.%s.", domain)
if !followCNAME {
return fqdn
}
// recursion counter so it doesn't spin out of control
for limit := 0; limit < 50; limit++ {
// Keep following CNAMEs
r, err := dnsQuery(fqdn, dns.TypeCNAME, recursiveNameservers, true)
if err != nil || r.Rcode != dns.RcodeSuccess {
// No more CNAME records to follow, exit
break
}
// Check if the domain has CNAME then use that
cname := updateDomainWithCName(r, fqdn)
if cname == fqdn {
break
}
log.Infof("Found CNAME entry for %q: %q", fqdn, cname)
fqdn = cname
}
return fqdn
}

View file

@ -1,59 +0,0 @@
package dns01
import (
"bufio"
"fmt"
"os"
"time"
)
const (
dnsTemplate = `%s %d IN TXT %q`
)
// DNSProviderManual is an implementation of the ChallengeProvider interface.
type DNSProviderManual struct{}
// NewDNSProviderManual returns a DNSProviderManual instance.
func NewDNSProviderManual() (*DNSProviderManual, error) {
return &DNSProviderManual{}, nil
}
// Present prints instructions for manually creating the TXT record.
func (*DNSProviderManual) Present(domain, token, keyAuth string) error {
info := GetChallengeInfo(domain, keyAuth)
authZone, err := FindZoneByFqdn(info.EffectiveFQDN)
if err != nil {
return err
}
fmt.Printf("lego: Please create the following TXT record in your %s zone:\n", authZone)
fmt.Printf(dnsTemplate+"\n", info.EffectiveFQDN, DefaultTTL, info.Value)
fmt.Printf("lego: Press 'Enter' when you are done\n")
_, err = bufio.NewReader(os.Stdin).ReadBytes('\n')
return err
}
// CleanUp prints instructions for manually removing the TXT record.
func (*DNSProviderManual) CleanUp(domain, token, keyAuth string) error {
info := GetChallengeInfo(domain, keyAuth)
authZone, err := FindZoneByFqdn(info.EffectiveFQDN)
if err != nil {
return err
}
fmt.Printf("lego: You can now remove this TXT record from your %s zone:\n", authZone)
fmt.Printf(dnsTemplate+"\n", info.EffectiveFQDN, DefaultTTL, "...")
return nil
}
// Sequential All DNS challenges for this provider will be resolved sequentially.
// Returns the interval between each iteration.
func (d *DNSProviderManual) Sequential() time.Duration {
return DefaultPropagationTimeout
}

View file

@ -1,60 +0,0 @@
package dns01
import (
"io"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDNSProviderManual(t *testing.T) {
backupStdin := os.Stdin
defer func() { os.Stdin = backupStdin }()
testCases := []struct {
desc string
input string
expectError bool
}{
{
desc: "Press enter",
input: "ok\n",
},
{
desc: "Missing enter",
input: "ok",
expectError: true,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
file, err := os.CreateTemp("", "lego_test")
assert.NoError(t, err)
defer func() { _ = os.Remove(file.Name()) }()
_, err = file.WriteString(test.input)
assert.NoError(t, err)
_, err = file.Seek(0, io.SeekStart)
assert.NoError(t, err)
os.Stdin = file
manualProvider, err := NewDNSProviderManual()
require.NoError(t, err)
err = manualProvider.Present("example.com", "", "")
if test.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
err = manualProvider.CleanUp("example.com", "", "")
require.NoError(t, err)
}
})
}
}

View file

@ -1,283 +0,0 @@
package dns01
import (
"crypto/rand"
"crypto/rsa"
"errors"
"net/http"
"testing"
"time"
"github.com/go-acme/lego/v4/acme"
"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/stretchr/testify/require"
)
type providerMock struct {
present, cleanUp error
}
func (p *providerMock) Present(domain, token, keyAuth string) error { return p.present }
func (p *providerMock) CleanUp(domain, token, keyAuth string) error { return p.cleanUp }
type providerTimeoutMock struct {
present, cleanUp error
timeout, interval time.Duration
}
func (p *providerTimeoutMock) Present(domain, token, keyAuth string) error { return p.present }
func (p *providerTimeoutMock) CleanUp(domain, token, keyAuth string) error { return p.cleanUp }
func (p *providerTimeoutMock) Timeout() (time.Duration, time.Duration) { return p.timeout, p.interval }
func TestChallenge_PreSolve(t *testing.T) {
_, apiURL := tester.SetupFakeAPI(t)
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
require.NoError(t, err)
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
require.NoError(t, err)
testCases := []struct {
desc string
validate ValidateFunc
preCheck WrapPreCheckFunc
provider challenge.Provider
expectError bool
}{
{
desc: "success",
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil },
provider: &providerMock{},
},
{
desc: "validate fail",
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return errors.New("OOPS") },
preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil },
provider: &providerMock{
present: nil,
cleanUp: nil,
},
},
{
desc: "preCheck fail",
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return false, errors.New("OOPS") },
provider: &providerTimeoutMock{
timeout: 2 * time.Second,
interval: 500 * time.Millisecond,
},
},
{
desc: "present fail",
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil },
provider: &providerMock{
present: errors.New("OOPS"),
},
expectError: true,
},
{
desc: "cleanUp fail",
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil },
provider: &providerMock{
cleanUp: errors.New("OOPS"),
},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
chlg := NewChallenge(core, test.validate, test.provider, WrapPreCheck(test.preCheck))
authz := acme.Authorization{
Identifier: acme.Identifier{
Value: "example.com",
},
Challenges: []acme.Challenge{
{Type: challenge.DNS01.String()},
},
}
err = chlg.PreSolve(authz)
if test.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestChallenge_Solve(t *testing.T) {
_, apiURL := tester.SetupFakeAPI(t)
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
require.NoError(t, err)
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
require.NoError(t, err)
testCases := []struct {
desc string
validate ValidateFunc
preCheck WrapPreCheckFunc
provider challenge.Provider
expectError bool
}{
{
desc: "success",
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil },
provider: &providerMock{},
},
{
desc: "validate fail",
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return errors.New("OOPS") },
preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil },
provider: &providerMock{
present: nil,
cleanUp: nil,
},
expectError: true,
},
{
desc: "preCheck fail",
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return false, errors.New("OOPS") },
provider: &providerTimeoutMock{
timeout: 2 * time.Second,
interval: 500 * time.Millisecond,
},
expectError: true,
},
{
desc: "present fail",
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil },
provider: &providerMock{
present: errors.New("OOPS"),
},
},
{
desc: "cleanUp fail",
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil },
provider: &providerMock{
cleanUp: errors.New("OOPS"),
},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
var options []ChallengeOption
if test.preCheck != nil {
options = append(options, WrapPreCheck(test.preCheck))
}
chlg := NewChallenge(core, test.validate, test.provider, options...)
authz := acme.Authorization{
Identifier: acme.Identifier{
Value: "example.com",
},
Challenges: []acme.Challenge{
{Type: challenge.DNS01.String()},
},
}
err = chlg.Solve(authz)
if test.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestChallenge_CleanUp(t *testing.T) {
_, apiURL := tester.SetupFakeAPI(t)
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
require.NoError(t, err)
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
require.NoError(t, err)
testCases := []struct {
desc string
validate ValidateFunc
preCheck WrapPreCheckFunc
provider challenge.Provider
expectError bool
}{
{
desc: "success",
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil },
provider: &providerMock{},
},
{
desc: "validate fail",
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return errors.New("OOPS") },
preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil },
provider: &providerMock{
present: nil,
cleanUp: nil,
},
},
{
desc: "preCheck fail",
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return false, errors.New("OOPS") },
provider: &providerTimeoutMock{
timeout: 2 * time.Second,
interval: 500 * time.Millisecond,
},
},
{
desc: "present fail",
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil },
provider: &providerMock{
present: errors.New("OOPS"),
},
},
{
desc: "cleanUp fail",
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil },
provider: &providerMock{
cleanUp: errors.New("OOPS"),
},
expectError: true,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
chlg := NewChallenge(core, test.validate, test.provider, WrapPreCheck(test.preCheck))
authz := acme.Authorization{
Identifier: acme.Identifier{
Value: "example.com",
},
Challenges: []acme.Challenge{
{Type: challenge.DNS01.String()},
},
}
err = chlg.CleanUp(authz)
if test.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

View file

@ -1,24 +0,0 @@
package dns01
import (
"fmt"
"strings"
"github.com/miekg/dns"
)
// ExtractSubDomain extracts the subdomain part from a domain and a zone.
func ExtractSubDomain(domain, zone string) (string, error) {
canonDomain := dns.Fqdn(domain)
canonZone := dns.Fqdn(zone)
if canonDomain == canonZone {
return "", fmt.Errorf("no subdomain because the domain and the zone are identical: %s", canonDomain)
}
if !dns.IsSubDomain(canonZone, canonDomain) {
return "", fmt.Errorf("%s is not a subdomain of %s", canonDomain, canonZone)
}
return strings.TrimSuffix(canonDomain, "."+canonZone), nil
}

View file

@ -1,104 +0,0 @@
package dns01
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtractSubDomain(t *testing.T) {
testCases := []struct {
desc string
domain string
zone string
expected string
}{
{
desc: "no FQDN",
domain: "_acme-challenge.example.com",
zone: "example.com",
expected: "_acme-challenge",
},
{
desc: "no FQDN zone",
domain: "_acme-challenge.example.com.",
zone: "example.com",
expected: "_acme-challenge",
},
{
desc: "no FQDN domain",
domain: "_acme-challenge.example.com",
zone: "example.com.",
expected: "_acme-challenge",
},
{
desc: "FQDN",
domain: "_acme-challenge.example.com.",
zone: "example.com.",
expected: "_acme-challenge",
},
{
desc: "multi-level subdomain",
domain: "_acme-challenge.one.example.com.",
zone: "example.com.",
expected: "_acme-challenge.one",
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
subDomain, err := ExtractSubDomain(test.domain, test.zone)
require.NoError(t, err)
assert.Equal(t, test.expected, subDomain)
})
}
}
func TestExtractSubDomain_errors(t *testing.T) {
testCases := []struct {
desc string
domain string
zone string
}{
{
desc: "same domain",
domain: "example.com",
zone: "example.com",
},
{
desc: "same domain, no FQDN zone",
domain: "example.com.",
zone: "example.com",
},
{
desc: "same domain, no FQDN domain",
domain: "example.com",
zone: "example.com.",
},
{
desc: "same domain, FQDN",
domain: "example.com.",
zone: "example.com.",
},
{
desc: "zone and domain are unrelated",
domain: "_acme-challenge.example.com",
zone: "example.org",
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
_, err := ExtractSubDomain(test.domain, test.zone)
require.Error(t, err)
})
}
}

View file

@ -1,5 +0,0 @@
domain company.com
nameserver 10.200.3.249
nameserver 10.200.3.250:5353
nameserver 2001:4860:4860::8844
nameserver [10.0.0.1]:5353

View file

@ -1,19 +0,0 @@
package dns01
// ToFqdn converts the name into a fqdn appending a trailing dot.
func ToFqdn(name string) string {
n := len(name)
if n == 0 || name[n-1] == '.' {
return name
}
return name + "."
}
// UnFqdn converts the fqdn into a name removing the trailing dot.
func UnFqdn(name string) string {
n := len(name)
if n != 0 && name[n-1] == '.' {
return name[:n-1]
}
return name
}

View file

@ -1,66 +0,0 @@
package dns01
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestToFqdn(t *testing.T) {
testCases := []struct {
desc string
domain string
expected string
}{
{
desc: "simple",
domain: "foo.bar.com",
expected: "foo.bar.com.",
},
{
desc: "already FQDN",
domain: "foo.bar.com.",
expected: "foo.bar.com.",
},
}
for _, test := range testCases {
test := test
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
fqdn string
expected string
}{
{
desc: "simple",
fqdn: "foo.bar.com.",
expected: "foo.bar.com",
},
{
desc: "already domain",
fqdn: "foo.bar.com",
expected: "foo.bar.com",
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
domain := UnFqdn(test.fqdn)
assert.Equal(t, test.expected, domain)
})
}
}

View file

@ -1,290 +0,0 @@
package dns01
import (
"errors"
"fmt"
"net"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/miekg/dns"
)
const defaultResolvConf = "/etc/resolv.conf"
var (
fqdnSoaCache = map[string]*soaCacheEntry{}
muFqdnSoaCache sync.Mutex
)
var defaultNameservers = []string{
"google-public-dns-a.google.com:53",
"google-public-dns-b.google.com:53",
}
// recursiveNameservers are used to pre-check DNS propagation.
var recursiveNameservers = getNameservers(defaultResolvConf, defaultNameservers)
// soaCacheEntry holds a cached SOA record (only selected fields).
type soaCacheEntry struct {
zone string // zone apex (a domain name)
primaryNs string // primary nameserver for the zone apex
expires time.Time // time when this cache entry should be evicted
}
func newSoaCacheEntry(soa *dns.SOA) *soaCacheEntry {
return &soaCacheEntry{
zone: soa.Hdr.Name,
primaryNs: soa.Ns,
expires: time.Now().Add(time.Duration(soa.Refresh) * time.Second),
}
}
// isExpired checks whether a cache entry should be considered expired.
func (cache *soaCacheEntry) isExpired() bool {
return time.Now().After(cache.expires)
}
// ClearFqdnCache clears the cache of fqdn to zone mappings. Primarily used in testing.
func ClearFqdnCache() {
muFqdnSoaCache.Lock()
fqdnSoaCache = map[string]*soaCacheEntry{}
muFqdnSoaCache.Unlock()
}
func AddDNSTimeout(timeout time.Duration) ChallengeOption {
return func(_ *Challenge) error {
dnsTimeout = timeout
return nil
}
}
func AddRecursiveNameservers(nameservers []string) ChallengeOption {
return func(_ *Challenge) error {
recursiveNameservers = ParseNameservers(nameservers)
return nil
}
}
// getNameservers attempts to get systems nameservers before falling back to the defaults.
func getNameservers(path string, defaults []string) []string {
config, err := dns.ClientConfigFromFile(path)
if err != nil || len(config.Servers) == 0 {
return defaults
}
return ParseNameservers(config.Servers)
}
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 {
resolvers = append(resolvers, net.JoinHostPort(resolver, "53"))
} else {
resolvers = append(resolvers, resolver)
}
}
return resolvers
}
// lookupNameservers returns the authoritative nameservers for the given fqdn.
func lookupNameservers(fqdn string) ([]string, error) {
var authoritativeNss []string
zone, err := FindZoneByFqdn(fqdn)
if err != nil {
return nil, fmt.Errorf("could not determine the zone: %w", err)
}
r, err := dnsQuery(zone, dns.TypeNS, recursiveNameservers, true)
if err != nil {
return nil, err
}
for _, rr := range r.Answer {
if ns, ok := rr.(*dns.NS); ok {
authoritativeNss = append(authoritativeNss, strings.ToLower(ns.Ns))
}
}
if len(authoritativeNss) > 0 {
return authoritativeNss, nil
}
return nil, errors.New("could not determine authoritative nameservers")
}
// FindPrimaryNsByFqdn determines the primary nameserver of the zone apex for the given fqdn
// by recursing up the domain labels until the nameserver returns a SOA record in the answer section.
func FindPrimaryNsByFqdn(fqdn string) (string, error) {
return FindPrimaryNsByFqdnCustom(fqdn, recursiveNameservers)
}
// FindPrimaryNsByFqdnCustom determines the primary nameserver of the zone apex for the given fqdn
// by recursing up the domain labels until the nameserver returns a SOA record in the answer section.
func FindPrimaryNsByFqdnCustom(fqdn string, nameservers []string) (string, error) {
soa, err := lookupSoaByFqdn(fqdn, nameservers)
if err != nil {
return "", err
}
return soa.primaryNs, nil
}
// FindZoneByFqdn determines the zone apex for the given fqdn
// by recursing up the domain labels until the nameserver returns a SOA record in the answer section.
func FindZoneByFqdn(fqdn string) (string, error) {
return FindZoneByFqdnCustom(fqdn, recursiveNameservers)
}
// FindZoneByFqdnCustom determines the zone apex for the given fqdn
// by recursing up the domain labels until the nameserver returns a SOA record in the answer section.
func FindZoneByFqdnCustom(fqdn string, nameservers []string) (string, error) {
soa, err := lookupSoaByFqdn(fqdn, nameservers)
if err != nil {
return "", err
}
return soa.zone, nil
}
func lookupSoaByFqdn(fqdn string, nameservers []string) (*soaCacheEntry, error) {
muFqdnSoaCache.Lock()
defer muFqdnSoaCache.Unlock()
// Do we have it cached and is it still fresh?
if ent := fqdnSoaCache[fqdn]; ent != nil && !ent.isExpired() {
return ent, nil
}
ent, err := fetchSoaByFqdn(fqdn, nameservers)
if err != nil {
return nil, err
}
fqdnSoaCache[fqdn] = ent
return ent, nil
}
func fetchSoaByFqdn(fqdn string, nameservers []string) (*soaCacheEntry, error) {
var err error
var in *dns.Msg
labelIndexes := dns.Split(fqdn)
for _, index := range labelIndexes {
domain := fqdn[index:]
in, err = dnsQuery(domain, dns.TypeSOA, nameservers, true)
if err != nil {
continue
}
if in == nil {
continue
}
switch in.Rcode {
case dns.RcodeSuccess:
// Check if we got a SOA RR in the answer section
if len(in.Answer) == 0 {
continue
}
// CNAME records cannot/should not exist at the root of a zone.
// So we skip a domain when a CNAME is found.
if dnsMsgContainsCNAME(in) {
continue
}
for _, ans := range in.Answer {
if soa, ok := ans.(*dns.SOA); ok {
return newSoaCacheEntry(soa), nil
}
}
case dns.RcodeNameError:
// NXDOMAIN
default:
// Any response code other than NOERROR and NXDOMAIN is treated as error
return nil, fmt.Errorf("unexpected response code '%s' for %s", dns.RcodeToString[in.Rcode], domain)
}
}
return nil, fmt.Errorf("could not find the start of authority for %s%s", fqdn, formatDNSError(in, err))
}
// dnsMsgContainsCNAME checks for a CNAME answer in msg.
func dnsMsgContainsCNAME(msg *dns.Msg) bool {
for _, ans := range msg.Answer {
if _, ok := ans.(*dns.CNAME); ok {
return true
}
}
return false
}
func dnsQuery(fqdn string, rtype uint16, nameservers []string, recursive bool) (*dns.Msg, error) {
m := createDNSMsg(fqdn, rtype, recursive)
var in *dns.Msg
var err error
for _, ns := range nameservers {
in, err = sendDNSQuery(m, ns)
if err == nil && len(in.Answer) > 0 {
break
}
}
return in, err
}
func createDNSMsg(fqdn string, rtype uint16, recursive bool) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(fqdn, rtype)
m.SetEdns0(4096, false)
if !recursive {
m.RecursionDesired = false
}
return m
}
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}
in, _, err := tcp.Exchange(m, ns)
return in, err
}
udp := &dns.Client{Net: "udp", Timeout: dnsTimeout}
in, _, err := udp.Exchange(m, ns)
if in != nil && in.Truncated {
tcp := &dns.Client{Net: "tcp", Timeout: dnsTimeout}
// If the TCP request succeeds, the err will reset to nil
in, _, err = tcp.Exchange(m, ns)
}
return in, err
}
func formatDNSError(msg *dns.Msg, err error) string {
var parts []string
if msg != nil {
parts = append(parts, dns.RcodeToString[msg.Rcode])
}
if err != nil {
parts = append(parts, err.Error())
}
if len(parts) > 0 {
return ": " + strings.Join(parts, " ")
}
return ""
}

View file

@ -1,199 +0,0 @@
package dns01
import (
"sort"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLookupNameserversOK(t *testing.T) {
testCases := []struct {
fqdn string
nss []string
}{
{
fqdn: "en.wikipedia.org.",
nss: []string{"ns0.wikimedia.org.", "ns1.wikimedia.org.", "ns2.wikimedia.org."},
},
{
fqdn: "www.google.com.",
nss: []string{"ns1.google.com.", "ns2.google.com.", "ns3.google.com.", "ns4.google.com."},
},
{
fqdn: "physics.georgetown.edu.",
nss: []string{"ns4.georgetown.edu.", "ns5.georgetown.edu.", "ns6.georgetown.edu."},
},
}
for _, test := range testCases {
test := test
t.Run(test.fqdn, func(t *testing.T) {
t.Parallel()
nss, err := lookupNameservers(test.fqdn)
require.NoError(t, err)
sort.Strings(nss)
sort.Strings(test.nss)
assert.EqualValues(t, test.nss, nss)
})
}
}
func TestLookupNameserversErr(t *testing.T) {
testCases := []struct {
desc string
fqdn string
error string
}{
{
desc: "invalid tld",
fqdn: "_null.n0n0.",
error: "could not determine the zone",
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
_, err := lookupNameservers(test.fqdn)
require.Error(t, err)
assert.Contains(t, err.Error(), test.error)
})
}
}
var findXByFqdnTestCases = []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: "could not find the start of authority for test.lego.zz.: 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"},
expectedError: "could not find the start of authority for mail.google.com.: read udp",
},
{
desc: "no nameservers",
fqdn: "test.ldez.com.",
zone: "ldez.com.",
nameservers: []string{},
expectedError: "could not find the start of authority for test.ldez.com.",
},
}
func TestFindZoneByFqdnCustom(t *testing.T) {
for _, test := range findXByFqdnTestCases {
t.Run(test.desc, func(t *testing.T) {
ClearFqdnCache()
zone, err := FindZoneByFqdnCustom(test.fqdn, test.nameservers)
if test.expectedError != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), test.expectedError)
} else {
require.NoError(t, err)
assert.Equal(t, test.zone, zone)
}
})
}
}
func TestFindPrimaryNsByFqdnCustom(t *testing.T) {
for _, test := range findXByFqdnTestCases {
t.Run(test.desc, func(t *testing.T) {
ClearFqdnCache()
ns, err := FindPrimaryNsByFqdnCustom(test.fqdn, test.nameservers)
if test.expectedError != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), test.expectedError)
} else {
require.NoError(t, err)
assert.Equal(t, test.primaryNs, ns)
}
})
}
}
func TestResolveConfServers(t *testing.T) {
testCases := []struct {
fixture string
expected []string
defaults []string
}{
{
fixture: "fixtures/resolv.conf.1",
defaults: []string{"127.0.0.1:53"},
expected: []string{"10.200.3.249:53", "10.200.3.250:5353", "[2001:4860:4860::8844]:53", "[10.0.0.1]:5353"},
},
{
fixture: "fixtures/resolv.conf.nonexistant",
defaults: []string{"127.0.0.1:53"},
expected: []string{"127.0.0.1:53"},
},
}
for _, test := range testCases {
t.Run(test.fixture, func(t *testing.T) {
result := getNameservers(test.fixture, test.defaults)
sort.Strings(result)
sort.Strings(test.expected)
assert.Equal(t, test.expected, result)
})
}
}

View file

@ -1,8 +0,0 @@
//go:build !windows
package dns01
import "time"
// dnsTimeout is used to override the default DNS timeout of 10 seconds.
var dnsTimeout = 10 * time.Second

View file

@ -1,8 +0,0 @@
//go:build windows
package dns01
import "time"
// dnsTimeout is used to override the default DNS timeout of 20 seconds.
var dnsTimeout = 20 * time.Second

View file

@ -1,110 +0,0 @@
package dns01
import (
"fmt"
"net"
"strings"
"github.com/miekg/dns"
)
// PreCheckFunc checks DNS propagation before notifying ACME that the DNS challenge is ready.
type PreCheckFunc func(fqdn, value string) (bool, error)
// WrapPreCheckFunc wraps a PreCheckFunc in order to do extra operations before or after
// the main check, put it in a loop, etc.
type WrapPreCheckFunc func(domain, fqdn, value string, check PreCheckFunc) (bool, error)
// WrapPreCheck Allow to define checks before notifying ACME that the DNS challenge is ready.
func WrapPreCheck(wrap WrapPreCheckFunc) ChallengeOption {
return func(chlg *Challenge) error {
chlg.preCheck.checkFunc = wrap
return nil
}
}
func DisableCompletePropagationRequirement() ChallengeOption {
return func(chlg *Challenge) error {
chlg.preCheck.requireCompletePropagation = false
return nil
}
}
type preCheck struct {
// checks DNS propagation before notifying ACME that the DNS challenge is ready.
checkFunc WrapPreCheckFunc
// require the TXT record to be propagated to all authoritative name servers
requireCompletePropagation bool
}
func newPreCheck() preCheck {
return preCheck{
requireCompletePropagation: true,
}
}
func (p preCheck) call(domain, fqdn, value string) (bool, error) {
if p.checkFunc == nil {
return p.checkDNSPropagation(fqdn, value)
}
return p.checkFunc(domain, fqdn, value, p.checkDNSPropagation)
}
// checkDNSPropagation checks if the expected TXT record has been propagated to all authoritative nameservers.
func (p preCheck) checkDNSPropagation(fqdn, value string) (bool, error) {
// Initial attempt to resolve at the recursive NS
r, err := dnsQuery(fqdn, dns.TypeTXT, recursiveNameservers, true)
if err != nil {
return false, err
}
if !p.requireCompletePropagation {
return true, nil
}
if r.Rcode == dns.RcodeSuccess {
fqdn = updateDomainWithCName(r, fqdn)
}
authoritativeNss, err := lookupNameservers(fqdn)
if err != nil {
return false, err
}
return checkAuthoritativeNss(fqdn, value, authoritativeNss)
}
// checkAuthoritativeNss queries each of the given nameservers for the expected TXT record.
func checkAuthoritativeNss(fqdn, value string, nameservers []string) (bool, error) {
for _, ns := range nameservers {
r, err := dnsQuery(fqdn, dns.TypeTXT, []string{net.JoinHostPort(ns, "53")}, false)
if err != nil {
return false, err
}
if r.Rcode != dns.RcodeSuccess {
return false, fmt.Errorf("NS %s returned %s for %s", ns, dns.RcodeToString[r.Rcode], fqdn)
}
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
break
}
}
}
if !found {
return false, fmt.Errorf("NS %s did not return the expected TXT record [fqdn: %s, value: %s]: %s", ns, fqdn, value, strings.Join(records, " ,"))
}
}
return true, nil
}

View file

@ -1,117 +0,0 @@
package dns01
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCheckDNSPropagation(t *testing.T) {
testCases := []struct {
desc string
fqdn string
value string
expectError bool
}{
{
desc: "success",
fqdn: "postman-echo.com.",
value: "postman-domain-verification=c85de626cb79d941310696e06558e2e790223802f3697dfbdcaf65510152d52c",
},
{
desc: "no TXT record",
fqdn: "acme-staging.api.letsencrypt.org.",
value: "fe01=",
expectError: true,
},
}
for _, test := range testCases {
test := test
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 failed for %s", test.fqdn)
assert.False(t, ok, "PreCheckDNS must failed for %s", test.fqdn)
} else {
assert.NoErrorf(t, err, "PreCheckDNS failed for %s", test.fqdn)
assert.True(t, ok, "PreCheckDNS failed for %s", test.fqdn)
}
})
}
}
func TestCheckAuthoritativeNss(t *testing.T) {
testCases := []struct {
desc string
fqdn, value string
ns []string
expected bool
}{
{
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: "No TXT RR",
fqdn: "ns1.google.com.",
ns: []string{"ns2.google.com."},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
ClearFqdnCache()
ok, _ := checkAuthoritativeNss(test.fqdn, test.value, test.ns)
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",
fqdn: "ns1.google.com.",
value: "fe01=",
ns: []string{"ns2.google.com."},
error: "did not return the expected TXT record",
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
ClearFqdnCache()
_, err := checkAuthoritativeNss(test.fqdn, test.value, test.ns)
require.Error(t, err)
assert.Contains(t, err.Error(), test.error)
})
}
}

View file

@ -1,187 +0,0 @@
package http01
import (
"fmt"
"net/http"
"strings"
)
// A domainMatcher tries to match a domain (the one we're requesting a certificate for)
// in the HTTP request coming from the ACME validation servers.
// This step is part of DNS rebind attack prevention,
// where the webserver matches incoming requests to a list of domain the server acts authoritative for.
//
// The most simple check involves finding the domain in the HTTP Host header;
// this is what hostMatcher does.
// Use it, when the http01.ProviderServer is directly reachable from the internet,
// or when it operates behind a transparent proxy.
//
// In many (reverse) proxy setups, Apache and NGINX traditionally move the Host header to a new header named X-Forwarded-Host.
// Use arbitraryMatcher("X-Forwarded-Host") in this case,
// or the appropriate header name for other proxy servers.
//
// RFC7239 has standardized the different forwarding headers into a single header named Forwarded.
// The header value has a different format, so you should use forwardedMatcher
// when the http01.ProviderServer operates behind a RFC7239 compatible proxy.
// https://www.rfc-editor.org/rfc/rfc7239.html
//
// Note: RFC7239 also reminds us, "that an HTTP list [...] may be split over multiple header fields" (section 7.1),
// meaning that
//
// X-Header: a
// X-Header: b
//
// is equal to
//
// X-Header: a, b
//
// All matcher implementations (explicitly not excluding arbitraryMatcher!)
// have in common that they only match against the first value in such lists.
type domainMatcher interface {
// matches checks whether the request is valid for the given domain.
matches(request *http.Request, domain string) bool
// name returns the header name used in the check.
// This is primarily used to create meaningful error messages.
name() string
}
// hostMatcher checks whether (*net/http).Request.Host starts with a domain name.
type hostMatcher struct{}
func (m *hostMatcher) name() string {
return "Host"
}
func (m *hostMatcher) matches(r *http.Request, domain string) bool {
return strings.HasPrefix(r.Host, domain)
}
// hostMatcher checks whether the specified (*net/http.Request).Header value starts with a domain name.
type arbitraryMatcher string
func (m arbitraryMatcher) name() string {
return string(m)
}
func (m arbitraryMatcher) matches(r *http.Request, domain string) bool {
return strings.HasPrefix(r.Header.Get(m.name()), domain)
}
// forwardedMatcher checks whether the Forwarded header contains a "host" element starting with a domain name.
// See https://www.rfc-editor.org/rfc/rfc7239.html for details.
type forwardedMatcher struct{}
func (m *forwardedMatcher) name() string {
return "Forwarded"
}
func (m *forwardedMatcher) matches(r *http.Request, domain string) bool {
fwds, err := parseForwardedHeader(r.Header.Get(m.name()))
if err != nil {
return false
}
if len(fwds) == 0 {
return false
}
host := fwds[0]["host"]
return strings.HasPrefix(host, domain)
}
// parsing requires some form of state machine.
func parseForwardedHeader(s string) (elements []map[string]string, err error) {
cur := make(map[string]string)
key := ""
val := ""
inquote := false
pos := 0
l := len(s)
for i := 0; i < l; i++ {
r := rune(s[i])
if inquote {
if r == '"' {
cur[key] = s[pos:i]
key = ""
pos = i
inquote = false
}
continue
}
switch {
case r == '"': // start of quoted-string
if key == "" {
return nil, fmt.Errorf("unexpected quoted string as pos %d", i)
}
inquote = true
pos = i + 1
case r == ';': // end of forwarded-pair
cur[key] = s[pos:i]
key = ""
i = skipWS(s, i)
pos = i + 1
case r == '=': // end of token
key = strings.ToLower(strings.TrimFunc(s[pos:i], isWS))
i = skipWS(s, i)
pos = i + 1
case r == ',': // end of forwarded-element
if key != "" {
if val == "" {
val = s[pos:i]
}
cur[key] = val
}
elements = append(elements, cur)
cur = make(map[string]string)
key = ""
val = ""
i = skipWS(s, i)
pos = i + 1
case tchar(r) || isWS(r): // valid token character or whitespace
continue
default:
return nil, fmt.Errorf("invalid token character at pos %d: %c", i, r)
}
}
if inquote {
return nil, fmt.Errorf("unterminated quoted-string at pos %d", len(s))
}
if key != "" {
if pos < len(s) {
val = s[pos:]
}
cur[key] = val
}
if len(cur) > 0 {
elements = append(elements, cur)
}
return elements, nil
}
func tchar(r rune) bool {
return strings.ContainsRune("!#$%&'*+-.^_`|~", r) ||
'0' <= r && r <= '9' ||
'a' <= r && r <= 'z' ||
'A' <= r && r <= 'Z'
}
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)
}

View file

@ -1,86 +0,0 @@
package http01
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseForwardedHeader(t *testing.T) {
testCases := []struct {
name string
input string
want []map[string]string
err string
}{
{
name: "empty input",
input: "",
want: nil,
},
{
name: "simple case",
input: `for=1.2.3.4;host=example.com; by=127.0.0.1`,
want: []map[string]string{
{"for": "1.2.3.4", "host": "example.com", "by": "127.0.0.1"},
},
},
{
name: "quoted-string",
input: `foo="bar"`,
want: []map[string]string{
{"foo": "bar"},
},
},
{
name: "multiple entries",
input: `a=1, b=2; c=3, d=4`,
want: []map[string]string{
{"a": "1"},
{"b": "2", "c": "3"},
{"d": "4"},
},
},
{
name: "whitespace",
input: " a = 1,\tb\n=\r\n2,c=\" untrimmed \"",
want: []map[string]string{
{"a": "1"},
{"b": "2"},
{"c": " untrimmed "},
},
},
{
name: "unterminated quote",
input: `x="y`,
err: "unterminated quoted-string",
},
{
name: "unexpected quote",
input: `"x=y"`,
err: "unexpected quote",
},
{
name: "invalid token",
input: `a=b, ipv6=[fe80::1], x=y`,
err: "invalid token character at pos 10: [",
},
}
for _, test := range testCases {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
actual, err := parseForwardedHeader(test.input)
if test.err == "" {
require.NoError(t, err)
assert.EqualValues(t, test.want, actual)
} else {
require.Error(t, err)
assert.Contains(t, err.Error(), test.err)
}
})
}
}

View file

@ -1,65 +0,0 @@
package http01
import (
"fmt"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/log"
)
type ValidateFunc func(core *api.Core, domain string, chlng acme.Challenge) error
// ChallengePath returns the URL path for the `http-01` challenge.
func ChallengePath(token string) string {
return "/.well-known/acme-challenge/" + token
}
type Challenge struct {
core *api.Core
validate ValidateFunc
provider challenge.Provider
}
func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider) *Challenge {
return &Challenge{
core: core,
validate: validate,
provider: provider,
}
}
func (c *Challenge) SetProvider(provider challenge.Provider) {
c.provider = provider
}
func (c *Challenge) Solve(authz acme.Authorization) error {
domain := challenge.GetTargetedDomain(authz)
log.Infof("[%s] acme: Trying to solve HTTP-01", domain)
chlng, err := challenge.FindChallenge(challenge.HTTP01, authz)
if err != nil {
return err
}
// Generate the Key Authorization for the challenge
keyAuth, err := c.core.GetKeyAuthorization(chlng.Token)
if err != nil {
return err
}
err = c.provider.Present(authz.Identifier.Value, chlng.Token, keyAuth)
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 {
log.Warnf("[%s] acme: cleaning up failed: %v", domain, err)
}
}()
chlng.KeyAuthorization = keyAuth
return c.validate(c.core, domain, chlng)
}

View file

@ -1,137 +0,0 @@
package http01
import (
"fmt"
"io/fs"
"net"
"net/http"
"net/textproto"
"os"
"strings"
"github.com/go-acme/lego/v4/log"
)
// ProviderServer implements ChallengeProvider for `http-01` challenge.
// It may be instantiated without using the NewProviderServer function if
// you want only to use the default values.
type ProviderServer struct {
address string
network string // must be valid argument to net.Listen
socketMode fs.FileMode
matcher domainMatcher
done chan bool
listener net.Listener
}
// NewProviderServer creates a new ProviderServer on the selected interface and port.
// Setting iface and / or port to an empty string will make the server fall back to
// the "any" interface and port 80 respectively.
func NewProviderServer(iface, port string) *ProviderServer {
if port == "" {
port = "80"
}
return &ProviderServer{network: "tcp", address: net.JoinHostPort(iface, port), matcher: &hostMatcher{}}
}
func NewUnixProviderServer(socketPath string, mode fs.FileMode) *ProviderServer {
return &ProviderServer{network: "unix", address: socketPath, socketMode: mode, matcher: &hostMatcher{}}
}
// 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)
}
if s.network == "unix" {
if err = os.Chmod(s.address, s.socketMode); err != nil {
return fmt.Errorf("chmod %s: %w", s.address, err)
}
}
s.done = make(chan bool)
go s.serve(domain, token, keyAuth)
return nil
}
func (s *ProviderServer) GetAddress() string {
return s.address
}
// CleanUp closes the HTTP server and removes the token from `ChallengePath(token)`.
func (s *ProviderServer) CleanUp(domain, token, keyAuth string) error {
if s.listener == nil {
return nil
}
s.listener.Close()
<-s.done
return nil
}
// SetProxyHeader changes the validation of incoming requests.
// By default, s matches the "Host" header value to the domain name.
//
// When the server runs behind a proxy server, this is not the correct place to look at;
// Apache and NGINX have traditionally moved the original Host header into a new header named "X-Forwarded-Host".
// Other webservers might use different names;
// and RFC7239 has standardized a new header named "Forwarded" (with slightly different semantics).
//
// The exact behavior depends on the value of headerName:
// - "" (the empty string) and "Host" will restore the default and only check the Host header
// - "Forwarded" will look for a Forwarded header, and inspect it according to https://www.rfc-editor.org/rfc/rfc7239.html
// - any other value will check the header value with the same name.
func (s *ProviderServer) SetProxyHeader(headerName string) {
switch h := textproto.CanonicalMIMEHeaderKey(headerName); h {
case "", "Host":
s.matcher = &hostMatcher{}
case "Forwarded":
s.matcher = &forwardedMatcher{}
default:
s.matcher = arbitraryMatcher(h)
}
}
func (s *ProviderServer) serve(domain, token, keyAuth string) {
path := ChallengePath(token)
// The incoming request will be validated to prevent DNS rebind attacks.
// We only respond with the keyAuth, when we're receiving a GET requests with
// the "Host" header matching the domain (the latter is configurable though SetProxyHeader).
mux := http.NewServeMux()
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
}
}
})
httpServer := &http.Server{Handler: mux}
// Once httpServer is shut down
// we don't want any lingering connections, so disable KeepAlives.
httpServer.SetKeepAlivesEnabled(false)
err := httpServer.Serve(s.listener)
if err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
log.Println(err)
}
s.done <- true
}

View file

@ -1,438 +0,0 @@
package http01
import (
"context"
"crypto/rand"
"crypto/rsa"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"net/textproto"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/go-acme/lego/v4/acme"
"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/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProviderServer_GetAddress(t *testing.T) {
dir := t.TempDir()
t.Cleanup(func() { _ = os.RemoveAll(dir) })
sock := filepath.Join(dir, "var", "run", "test")
testCases := []struct {
desc string
server *ProviderServer
expected string
}{
{
desc: "TCP default address",
server: NewProviderServer("", ""),
expected: ":80",
},
{
desc: "TCP with explicit port",
server: NewProviderServer("", "8080"),
expected: ":8080",
},
{
desc: "TCP with host and port",
server: NewProviderServer("localhost", "8080"),
expected: "localhost:8080",
},
{
desc: "UDS socket",
server: NewUnixProviderServer(sock, fs.ModeSocket|0o666),
expected: sock,
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
address := test.server.GetAddress()
assert.Equal(t, test.expected, address)
})
}
}
func TestChallenge(t *testing.T) {
_, apiURL := tester.SetupFakeAPI(t)
providerServer := NewProviderServer("", "23457")
validate := func(_ *api.Core, _ string, chlng acme.Challenge) error {
uri := "http://localhost" + providerServer.GetAddress() + ChallengePath(chlng.Token)
resp, err := http.DefaultClient.Get(uri)
if err != nil {
return err
}
defer resp.Body.Close()
if want := "text/plain"; resp.Header.Get("Content-Type") != want {
t.Errorf("Get(%q) Content-Type: got %q, want %q", uri, resp.Header.Get("Content-Type"), want)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
bodyStr := string(body)
if bodyStr != chlng.KeyAuthorization {
t.Errorf("Get(%q) Body: got %q, want %q", uri, bodyStr, chlng.KeyAuthorization)
}
return nil
}
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
require.NoError(t, err)
solver := NewChallenge(core, validate, providerServer)
authz := acme.Authorization{
Identifier: acme.Identifier{
Value: "localhost:23457",
},
Challenges: []acme.Challenge{
{Type: challenge.HTTP01.String(), Token: "http1"},
},
}
err = solver.Solve(authz)
require.NoError(t, err)
}
func TestChallengeUnix(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("only for UNIX systems")
}
_, apiURL := tester.SetupFakeAPI(t)
dir := t.TempDir()
t.Cleanup(func() { _ = os.RemoveAll(dir) })
socket := filepath.Join(dir, "lego-challenge-test.sock")
providerServer := NewUnixProviderServer(socket, fs.ModeSocket|0o666)
validate := func(_ *api.Core, _ string, chlng acme.Challenge) error {
// any uri will do, as we hijack the dial
uri := "http://localhost" + ChallengePath(chlng.Token)
client := &http.Client{Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return net.Dial("unix", socket)
},
}}
resp, err := client.Get(uri)
if err != nil {
return err
}
defer resp.Body.Close()
if want := "text/plain"; resp.Header.Get("Content-Type") != want {
t.Errorf("Get(%q) Content-Type: got %q, want %q", uri, resp.Header.Get("Content-Type"), want)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
bodyStr := string(body)
if bodyStr != chlng.KeyAuthorization {
t.Errorf("Get(%q) Body: got %q, want %q", uri, bodyStr, chlng.KeyAuthorization)
}
return nil
}
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
require.NoError(t, err)
solver := NewChallenge(core, validate, providerServer)
authz := acme.Authorization{
Identifier: acme.Identifier{
Value: "localhost",
},
Challenges: []acme.Challenge{
{Type: challenge.HTTP01.String(), Token: "http1"},
},
}
err = solver.Solve(authz)
require.NoError(t, err)
}
func TestChallengeInvalidPort(t *testing.T) {
_, apiURL := tester.SetupFakeAPI(t)
privateKey, err := rsa.GenerateKey(rand.Reader, 128)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
require.NoError(t, err)
validate := func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }
solver := NewChallenge(core, validate, NewProviderServer("", "123456"))
authz := acme.Authorization{
Identifier: acme.Identifier{
Value: "localhost:123456",
},
Challenges: []acme.Challenge{
{Type: challenge.HTTP01.String(), Token: "http2"},
},
}
err = solver.Solve(authz)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid port")
assert.Contains(t, err.Error(), "123456")
}
type testProxyHeader struct {
name string
values []string
}
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 != "" {
r.Header[h.name] = h.values
}
}
func TestChallengeWithProxy(t *testing.T) {
h := func(name string, values ...string) *testProxyHeader {
name = textproto.CanonicalMIMEHeaderKey(name)
return &testProxyHeader{name, values}
}
const (
ok = "localhost:23457"
nook = "example.com"
)
testCases := []struct {
name string
header *testProxyHeader
extra *testProxyHeader
isErr bool
}{
// tests for hostMatcher
{
name: "no proxy",
},
{
name: "empty string",
header: h(""),
},
{
name: "empty Host",
header: h("host"),
},
{
name: "matching Host",
header: h("host", ok),
},
{
name: "Host mismatch",
header: h("host", nook),
isErr: true,
},
{
name: "Host mismatch (ignoring forwarding header)",
header: h("host", nook),
extra: h("X-Forwarded-Host", ok),
isErr: true,
},
// test for arbitraryMatcher
{
name: "matching X-Forwarded-Host",
header: h("X-Forwarded-Host", ok),
},
{
name: "matching X-Forwarded-Host (multiple fields)",
header: h("X-Forwarded-Host", ok, nook),
},
{
name: "matching X-Forwarded-Host (chain value)",
header: h("X-Forwarded-Host", ok+", "+nook),
},
{
name: "X-Forwarded-Host mismatch",
header: h("X-Forwarded-Host", nook),
extra: h("host", ok),
isErr: true,
},
{
name: "X-Forwarded-Host mismatch (multiple fields)",
header: h("X-Forwarded-Host", nook, ok),
isErr: true,
},
{
name: "matching X-Something-Else",
header: h("X-Something-Else", ok),
},
{
name: "matching X-Something-Else (multiple fields)",
header: h("X-Something-Else", ok, nook),
},
{
name: "matching X-Something-Else (chain value)",
header: h("X-Something-Else", ok+", "+nook),
},
{
name: "X-Something-Else mismatch",
header: h("X-Something-Else", nook),
isErr: true,
},
{
name: "X-Something-Else mismatch (multiple fields)",
header: h("X-Something-Else", nook, ok),
isErr: true,
},
{
name: "X-Something-Else mismatch (chain value)",
header: h("X-Something-Else", nook+", "+ok),
isErr: true,
},
// tests for forwardedHeader
{
name: "matching Forwarded",
header: h("Forwarded", fmt.Sprintf("host=%q;foo=bar", ok)),
},
{
name: "matching Forwarded (multiple fields)",
header: h("Forwarded", fmt.Sprintf("host=%q", ok), "host="+nook),
},
{
name: "matching Forwarded (chain value)",
header: h("Forwarded", fmt.Sprintf("host=%q, host=%s", ok, nook)),
},
{
name: "Forwarded mismatch",
header: h("Forwarded", "host="+nook),
isErr: true,
},
{
name: "Forwarded mismatch (missing information)",
header: h("Forwarded", "for=127.0.0.1"),
isErr: true,
},
{
name: "Forwarded mismatch (multiple fields)",
header: h("Forwarded", "host="+nook, fmt.Sprintf("host=%q", ok)),
isErr: true,
},
{
name: "Forwarded mismatch (chain value)",
header: h("Forwarded", fmt.Sprintf("host=%s, host=%q", nook, ok)),
isErr: true,
},
}
for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
testServeWithProxy(t, test.header, test.extra, test.isErr)
})
}
}
func testServeWithProxy(t *testing.T, header, extra *testProxyHeader, expectError bool) {
t.Helper()
_, apiURL := tester.SetupFakeAPI(t)
providerServer := NewProviderServer("localhost", "23457")
if header != nil {
providerServer.SetProxyHeader(header.name)
}
validate := func(_ *api.Core, _ string, chlng acme.Challenge) error {
uri := "http://" + providerServer.GetAddress() + ChallengePath(chlng.Token)
req, err := http.NewRequest(http.MethodGet, uri, nil)
if err != nil {
return err
}
header.update(req)
extra.update(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if want := "text/plain"; resp.Header.Get("Content-Type") != want {
return fmt.Errorf("Get(%q) Content-Type: got %q, want %q", uri, resp.Header.Get("Content-Type"), want)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
bodyStr := string(body)
if bodyStr != chlng.KeyAuthorization {
return fmt.Errorf("Get(%q) Body: got %q, want %q", uri, bodyStr, chlng.KeyAuthorization)
}
return nil
}
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
require.NoError(t, err)
solver := NewChallenge(core, validate, providerServer)
authz := acme.Authorization{
Identifier: acme.Identifier{
Value: "localhost:23457",
},
Challenges: []acme.Challenge{
{Type: challenge.HTTP01.String(), Token: "http1"},
},
}
err = solver.Solve(authz)
if expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
}

View file

@ -1,125 +0,0 @@
package nns01
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"time"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/log"
)
type ValidateFunc func(core *api.Core, domain string, chlng acme.Challenge) error
// Challenge implements the nns-01 challenge.
type Challenge struct {
core *api.Core
validate ValidateFunc
provider challenge.Provider
}
func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider) *Challenge {
chlg := &Challenge{
core: core,
validate: validate,
provider: provider,
}
return chlg
}
// PreSolve submits the txt record to the nns provider.
func (c *Challenge) PreSolve(authz acme.Authorization) error {
domain := challenge.GetTargetedDomain(authz)
log.Infof("[%s] acme: Preparing to solve NNS-01", domain)
chlng, keyAuth, err := c.getChallengeInfo(authz)
if err != nil {
return err
}
err = c.provider.Present(authz.Identifier.Value, chlng.Token, keyAuth)
if err != nil {
return fmt.Errorf("[%s] acme: error presenting token: %w", domain, err)
}
return nil
}
func (c *Challenge) Solve(authz acme.Authorization) error {
domain := challenge.GetTargetedDomain(authz)
log.Infof("[%s] acme: Trying to solve NNS-01", domain)
chlng, keyAuth, err := c.getChallengeInfo(authz)
if err != nil {
return err
}
chlng.KeyAuthorization = keyAuth
return c.validate(c.core, domain, chlng)
}
// CleanUp cleans the challenge.
func (c *Challenge) CleanUp(authz acme.Authorization) error {
log.Infof("[%s] acme: Cleaning NNS-01 challenge", challenge.GetTargetedDomain(authz))
chlng, keyAuth, err := c.getChallengeInfo(authz)
if err != nil {
return err
}
return c.provider.CleanUp(authz.Identifier.Value, chlng.Token, keyAuth)
}
func (c *Challenge) Sequential() (bool, time.Duration) {
if p, ok := c.provider.(sequential); ok {
return ok, p.Sequential()
}
return false, 0
}
type sequential interface {
Sequential() time.Duration
}
// RecordInfo contains the information use to create the TXT record.
type RecordInfo struct {
// FQDN is the full-qualified challenge domain (`acme-challenge.[domain]`)
FQDN string
// Value contains the value for the TXT record
Value string
}
// GetRecordInfo returns information used to create a TXT record which will fulfill the `nns-01` challenge.
func GetRecordInfo(domain, keyAuth string) RecordInfo {
keyAuthShaBytes := sha256.Sum256([]byte(keyAuth))
// base64URL encoding without padding
value := base64.RawURLEncoding.EncodeToString(keyAuthShaBytes[:sha256.Size])
return RecordInfo{
Value: value,
FQDN: getRecordFQDN(domain),
}
}
func getRecordFQDN(domain string) string {
return fmt.Sprintf("acme-challenge.%s", domain)
}
func (c *Challenge) getChallengeInfo(authz acme.Authorization) (acme.Challenge, string, error) {
chlng, err := challenge.FindChallenge(challenge.NNS01, authz)
if err != nil {
return acme.Challenge{}, "", err
}
// Generate the Key Authorization for the challenge
keyAuth, err := c.core.GetKeyAuthorization(chlng.Token)
if err != nil {
return acme.Challenge{}, "", err
}
return chlng, keyAuth, nil
}

View file

@ -1,171 +0,0 @@
package nns01
import (
"context"
"fmt"
"net/url"
"git.frostfs.info/TrueCloudLab/frostfs-contract/nns"
"github.com/nspcc-dev/neo-go/cli/flags"
"github.com/nspcc-dev/neo-go/pkg/core/state"
"github.com/nspcc-dev/neo-go/pkg/rpcclient"
"github.com/nspcc-dev/neo-go/pkg/rpcclient/actor"
"github.com/nspcc-dev/neo-go/pkg/util"
"github.com/nspcc-dev/neo-go/pkg/wallet"
)
// multiSchemeClient unites invoker.RPCInvoke and common interface of
// rpcclient.Client and rpcclient.WSClient.
type multiSchemeClient interface {
actor.RPCActor
actor.RPCPollingWaiter
// Init turns client to "ready-to-work" state.
Init() error
// Close closes connections.
Close()
// GetContractStateByID returns state of the NNS contract on 1 input.
GetContractStateByID(int32) (*state.Contract, error)
}
type NNSProvider struct {
nnsServer string
account *wallet.Account
nnsContract util.Uint160
client multiSchemeClient
}
// NewNNSProvider returns configured NNSProvider instance.
func NewNNSProvider(nnsServer string, walletFile string, accountAddress string, accountPassword string) (*NNSProvider, error) {
w, err := wallet.NewWalletFromFile(walletFile)
if err != nil {
return nil, fmt.Errorf("retrieve wallet from file: %w", err)
}
var address util.Uint160
if len(accountAddress) == 0 {
address = w.GetChangeAddress()
} else {
address, err = flags.ParseAddress(accountAddress)
if err != nil {
return nil, fmt.Errorf("parse account address: %w", err)
}
}
acc := w.GetAccount(address)
err = acc.Decrypt(accountPassword, w.Scrypt)
if err != nil {
return nil, fmt.Errorf("decrypt account: %w", err)
}
provider := NNSProvider{
nnsServer: nnsServer,
account: acc,
}
return &provider, nil
}
// dial connects to the address of the NNS server.
// If URL address scheme is 'ws' or 'wss', then WebSocket protocol is used, otherwise HTTP.
func (n *NNSProvider) dial() error {
var err error
uri, err := url.Parse(n.nnsServer)
if err == nil && (uri.Scheme == "ws" || uri.Scheme == "wss") {
// WSOptions not in package `github.com/nspcc-dev/neo-go v0.101.3`
n.client, err = rpcclient.NewWS(context.Background(), n.nnsServer, rpcclient.Options{})
if err != nil {
return fmt.Errorf("create Neo WebSocket client: %w", err)
}
} else {
n.client, err = rpcclient.New(context.Background(), n.nnsServer, rpcclient.Options{})
if err != nil {
return fmt.Errorf("create Neo HTTP client: %w", err)
}
}
if err = n.client.Init(); err != nil {
return fmt.Errorf("initialize Neo client: %w", err)
}
nnsContract, err := n.client.GetContractStateByID(1)
if err != nil {
return fmt.Errorf("get NNS contract state: %w", err)
}
n.nnsContract = nnsContract.Hash
return nil
}
// Close closes connections of multiSchemeClient.
func (n *NNSProvider) close() {
if n.client != nil {
n.client.Close()
}
}
// Present creates a TXT record using the specified parameters to fulfill the nns-01 challenge.
// It implements Provider interface in order to use NNSProvider as Provider.
func (n *NNSProvider) Present(domain, _, keyAuth string) error {
err := n.dial()
if err != nil {
return fmt.Errorf("connect to the NNS server: %w", err)
}
act, err := actor.NewSimple(n.client, n.account)
if err != nil {
return fmt.Errorf("create actor: %w", err)
}
info := GetRecordInfo(domain, keyAuth)
err = n.addTXTRecord(act, info.FQDN, info.Value)
if err != nil {
return fmt.Errorf("add txt record: %w", err)
}
return nil
}
// addTXTRecord adds a new TXT record with the specified data to the provided domain by calling `addRecord` method
// of NNS contract.
func (n *NNSProvider) addTXTRecord(act *actor.Actor, name string, data string) error {
waiter, err := actor.NewPollingWaiter(n.client)
if err != nil {
return fmt.Errorf("waiter creation: %w", err)
}
_, err = waiter.Wait(act.SendCall(n.nnsContract, "addRecord", name, int64(nns.TXT), data))
if err != nil {
return fmt.Errorf("contract invocation: %w", err)
}
return nil
}
// CleanUp removes the TXT record matching the specified parameters.
// It implements Provider interface in order to use NNSProvider as Provider.
func (n *NNSProvider) CleanUp(domain, _, keyAuth string) error {
defer n.close()
act, err := actor.NewSimple(n.client, n.account)
if err != nil {
return fmt.Errorf("create actor: %w", err)
}
info := GetRecordInfo(domain, keyAuth)
err = n.deleteTXTRecords(act, info.FQDN)
if err != nil {
return fmt.Errorf("delete txt records: %w", err)
}
return nil
}
// deleteTXTRecords removes TXT records from the provided domain by calling `deleteRecords` method of NNS contract.
func (n *NNSProvider) deleteTXTRecords(act *actor.Actor, name string) error {
waiter, err := actor.NewPollingWaiter(n.client)
if err != nil {
return fmt.Errorf("waiter creation: %w", err)
}
_, err = waiter.Wait(act.SendCall(n.nnsContract, "deleteRecords", name, int64(nns.TXT)))
if err != nil {
return fmt.Errorf("contract invocation: %w", err)
}
return nil
}

View file

@ -1,25 +0,0 @@
package resolver
import (
"bytes"
"fmt"
"sort"
)
// obtainError is returned when there are specific errors available per domain.
type obtainError map[string]error
func (e obtainError) Error() string {
buffer := bytes.NewBufferString("error: one or more domains had a problem:\n")
var domains []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()
}

View file

@ -1,173 +0,0 @@
package resolver
import (
"fmt"
"time"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/log"
)
// Interface for all challenge solvers to implement.
type solver interface {
Solve(authorization acme.Authorization) error
}
// Interface for challenges like dns, where we can set a record in advance for ALL challenges.
// This saves quite a bit of time vs creating the records and solving them serially.
type preSolver interface {
PreSolve(authorization acme.Authorization) error
}
// Interface for challenges like dns, where we can solve all the challenges before to delete them.
type cleanup interface {
CleanUp(authorization acme.Authorization) error
}
type sequential interface {
Sequential() (bool, time.Duration)
}
// an authz with the solver we have chosen and the index of the challenge associated with it.
type selectedAuthSolver struct {
authz acme.Authorization
solver solver
}
type Prober struct {
solverManager *SolverManager
}
func NewProber(solverManager *SolverManager) *Prober {
return &Prober{
solverManager: solverManager,
}
}
// Solve Looks through the challenge combinations to find a solvable match.
// Then solves the challenges in series and returns.
func (p *Prober) Solve(authorizations []acme.Authorization) error {
failures := make(obtainError)
var authSolvers []*selectedAuthSolver
var 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 {
// Boulder might recycle recent validated authz (see issue #267)
log.Infof("[%s] acme: authorization already valid; skipping challenge", domain)
continue
}
if solvr := p.solverManager.chooseSolver(authz); solvr != nil {
authSolver := &selectedAuthSolver{authz: authz, solver: solvr}
switch s := solvr.(type) {
case sequential:
if ok, _ := s.Sequential(); ok {
authSolversSequential = append(authSolversSequential, authSolver)
} else {
authSolvers = append(authSolvers, authSolver)
}
default:
authSolvers = append(authSolvers, authSolver)
}
} else {
failures[domain] = fmt.Errorf("[%s] acme: could not determine solvers", domain)
}
}
parallelSolve(authSolvers, failures)
sequentialSolve(authSolversSequential, failures)
// Be careful not to return an empty failures map,
// for even an empty obtainError is a non-nil error value
if len(failures) > 0 {
return failures
}
return nil
}
func sequentialSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
for i, authSolver := range authSolvers {
// Submit the challenge
domain := challenge.GetTargetedDomain(authSolver.authz)
if solvr, ok := authSolver.solver.(preSolver); ok {
err := solvr.PreSolve(authSolver.authz)
if err != nil {
failures[domain] = err
cleanUp(authSolver.solver, authSolver.authz)
continue
}
}
// 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 len(authSolvers)-1 > i {
solvr := authSolver.solver.(sequential)
_, interval := solvr.Sequential()
log.Infof("sequence: wait for %s", interval)
time.Sleep(interval)
}
}
}
func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
// For all valid preSolvers, first submit the challenges so they have max time to propagate
for _, authSolver := range authSolvers {
authz := authSolver.authz
if solvr, ok := authSolver.solver.(preSolver); ok {
err := solvr.PreSolve(authz)
if err != nil {
failures[challenge.GetTargetedDomain(authz)] = err
}
}
}
defer func() {
// Clean all created TXT records
for _, authSolver := range authSolvers {
cleanUp(authSolver.solver, authSolver.authz)
}
}()
// 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
continue
}
err := authSolver.solver.Solve(authz)
if err != nil {
failures[domain] = err
}
}
}
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)
}
}
}

View file

@ -1,44 +0,0 @@
package resolver
import (
"time"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/challenge"
)
type preSolverMock struct {
preSolve map[string]error
solve map[string]error
cleanUp map[string]error
}
func (s *preSolverMock) PreSolve(authorization acme.Authorization) error {
return s.preSolve[authorization.Identifier.Value]
}
func (s *preSolverMock) Solve(authorization acme.Authorization) error {
return s.solve[authorization.Identifier.Value]
}
func (s *preSolverMock) CleanUp(authorization acme.Authorization) error {
return s.cleanUp[authorization.Identifier.Value]
}
func createStubAuthorizationHTTP01(domain, status string) acme.Authorization {
return acme.Authorization{
Status: status,
Expires: time.Now(),
Identifier: acme.Identifier{
Type: challenge.HTTP01.String(),
Value: domain,
},
Challenges: []acme.Challenge{
{
Type: challenge.HTTP01.String(),
Validated: time.Now(),
Error: nil,
},
},
}
}

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