Compare commits
21 commits
tcl/master
...
acmev2
Author | SHA1 | Date | |
---|---|---|---|
|
9a1f8d748a | ||
|
1f0c69adc5 | ||
|
ff6a346e5a | ||
|
eed8169a03 | ||
|
56911d8e18 | ||
|
76cb7789ce | ||
|
2cdb0e9e8a | ||
|
32052a841e | ||
|
5115a955b2 | ||
|
8a990209a9 | ||
|
ef5b5bffb6 | ||
|
fad2257e11 | ||
|
823a03a417 | ||
|
6e962fbfb3 | ||
|
94083744ee | ||
|
5d4f14bc6a | ||
|
0b6d953434 | ||
|
c62cb65ba5 | ||
|
a149e7d650 | ||
|
805eec9756 | ||
|
3d582c0169 |
1166 changed files with 12301 additions and 117908 deletions
|
@ -1,8 +0,0 @@
|
||||||
lego.exe
|
|
||||||
.lego
|
|
||||||
.gitcookies
|
|
||||||
.idea
|
|
||||||
.vscode/
|
|
||||||
dist/
|
|
||||||
builds/
|
|
||||||
docs/
|
|
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
|
@ -1 +0,0 @@
|
||||||
github: ldez
|
|
104
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
104
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
@ -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
|
|
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
|
@ -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.
|
|
34
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
34
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
@ -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
|
|
60
.github/ISSUE_TEMPLATE/new_dns_provider.yml
vendored
60
.github/ISSUE_TEMPLATE/new_dns_provider.yml
vendored
|
@ -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
|
|
50
.github/workflows/documentation.yml
vendored
50
.github/workflows/documentation.yml
vendored
|
@ -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 }}
|
|
55
.github/workflows/go-cross.yml
vendored
55
.github/workflows/go-cross.yml
vendored
|
@ -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/
|
|
85
.github/workflows/pr.yml
vendored
85
.github/workflows/pr.yml
vendored
|
@ -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
|
|
63
.github/workflows/release.yml
vendored
63
.github/workflows/release.yml
vendored
|
@ -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 }}
|
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -1,6 +1,6 @@
|
||||||
|
lego.exe
|
||||||
|
lego
|
||||||
.lego
|
.lego
|
||||||
.gitcookies
|
|
||||||
.idea
|
.idea
|
||||||
.vscode/
|
|
||||||
dist/
|
dist/
|
||||||
builds/
|
.vscode/
|
225
.golangci.yml
225
.golangci.yml
|
@ -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'
|
|
||||||
|
|
135
.goreleaser.yml
135
.goreleaser.yml
|
@ -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'
|
|
16
.travis.yml
Normal file
16
.travis.yml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
language: go
|
||||||
|
go:
|
||||||
|
- 1.9.x
|
||||||
|
- 1.x
|
||||||
|
- tip
|
||||||
|
services:
|
||||||
|
- memcached
|
||||||
|
env:
|
||||||
|
- MEMCACHED_HOSTS=localhost:11211
|
||||||
|
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'
|
855
CHANGELOG.md
855
CHANGELOG.md
|
@ -1,850 +1,5 @@
|
||||||
# Changelog
|
# 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 -> 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 "LEGO_PATH" 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'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'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 Let’s Encrypt’s recommendation about renew. The option `--days` of the command `renew` has a new default value (`30`)
|
|
||||||
- **[lib]** Uses a jittered exponential backoff
|
|
||||||
|
|
||||||
### Fixed:
|
|
||||||
- **[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 "HTTP request".
|
|
||||||
- **[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
|
## [0.4.1] - 2017-09-26
|
||||||
|
|
||||||
### Added:
|
### Added:
|
||||||
|
@ -992,8 +147,8 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidate.
|
||||||
## [0.1.0] - 2015-12-03
|
## [0.1.0] - 2015-12-03
|
||||||
- Initial release
|
- Initial release
|
||||||
|
|
||||||
[0.3.1]: https://github.com/go-acme/lego/compare/v0.3.0...v0.3.1
|
[0.3.1]: https://github.com/xenolf/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.3.0]: https://github.com/xenolf/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.2.0]: https://github.com/xenolf/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.1]: https://github.com/xenolf/lego/compare/v0.1.0...v0.1.1
|
||||||
[0.1.0]: https://github.com/go-acme/lego/tree/v0.1.0
|
[0.1.0]: https://github.com/xenolf/lego/tree/v0.1.0
|
||||||
|
|
|
@ -20,7 +20,7 @@ It is up to you to make a strong point about your proposal and convince us of th
|
||||||
|
|
||||||
## Pull requests
|
## Pull requests
|
||||||
|
|
||||||
Patches, new features and improvements are a great way to help the project.
|
Patches, new features and improvements are a great way to help the project.
|
||||||
Please keep them focused on one thing and do not include unrelated commits.
|
Please keep them focused on one thing and do not include unrelated commits.
|
||||||
|
|
||||||
All pull requests which alter the behaviour of the program, add new behaviour or somehow alter code in a non-trivial way should **always** include tests.
|
All pull requests which alter the behaviour of the program, add new behaviour or somehow alter code in a non-trivial way should **always** include tests.
|
||||||
|
@ -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
|
**IMPORTANT**: By submitting a patch, you agree to allow the project
|
||||||
owners to license your work under the terms of the [MIT License](LICENSE).
|
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 ##
|
|
||||||
```
|
|
||||||
|
|
31
Dockerfile
31
Dockerfile
|
@ -1,24 +1,15 @@
|
||||||
FROM golang:1-alpine as builder
|
FROM alpine:3.6
|
||||||
|
|
||||||
RUN apk --no-cache --no-progress add make git
|
ENV GOPATH /go
|
||||||
|
ENV LEGO_VERSION tags/v0.4.1
|
||||||
|
|
||||||
WORKDIR /go/lego
|
RUN apk update && apk add --no-cache --virtual run-dependencies ca-certificates && \
|
||||||
|
apk add --no-cache --virtual build-dependencies go git musl-dev && \
|
||||||
ENV GO111MODULE on
|
go get -u github.com/xenolf/lego && \
|
||||||
|
cd ${GOPATH}/src/github.com/xenolf/lego && \
|
||||||
# Download go modules
|
git checkout ${LEGO_VERSION} && \
|
||||||
COPY go.mod .
|
go build -o /usr/bin/lego . && \
|
||||||
COPY go.sum .
|
apk del build-dependencies && \
|
||||||
RUN go mod download
|
rm -rf ${GOPATH}
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
ENTRYPOINT [ "/usr/bin/lego" ]
|
ENTRYPOINT [ "/usr/bin/lego" ]
|
||||||
|
|
80
Makefile
80
Makefile
|
@ -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
|
|
307
README.md
307
README.md
|
@ -1,22 +1,36 @@
|
||||||
<div align="center">
|
# lego
|
||||||
<img alt="lego logo" src="./docs/static/images/lego-logo.min.svg">
|
Let's Encrypt client and ACME library written in Go
|
||||||
<p>Automatic Certificates and HTTPS for everyone.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
# 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)
|
||||||
|
[![Beerpay](https://beerpay.io/xenolf/lego/badge.svg)](https://beerpay.io/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)
|
#### Installation
|
||||||
[![Build Status](https://github.com//go-acme/lego/workflows/Main/badge.svg?branch=master)](https://github.com//go-acme/lego/actions)
|
lego supports both binary installs and install from source.
|
||||||
[![Docker Pulls](https://img.shields.io/docker/pulls/goacme/lego.svg)](https://hub.docker.com/r/goacme/lego/)
|
|
||||||
|
|
||||||
## 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 .
|
||||||
|
```
|
||||||
|
##### From the package manager
|
||||||
|
- [ArchLinux (AUR)](https://aur.archlinux.org/packages/lego-git):
|
||||||
|
```
|
||||||
|
yaourt -S lego-git
|
||||||
|
```
|
||||||
|
#### Features
|
||||||
|
|
||||||
- ACME v2 [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555.html)
|
|
||||||
- Support [RFC 8737](https://www.rfc-editor.org/rfc/rfc8737.html): TLS Application‑Layer Protocol Negotiation (ALPN) Challenge Extension
|
|
||||||
- Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): certificates for IP addresses
|
|
||||||
- Support [draft-ietf-acme-ari-01](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension
|
|
||||||
- Register with CA
|
- Register with CA
|
||||||
- Obtain certificates, both from scratch or with an existing CSR
|
- Obtain certificates, both from scratch or with an existing CSR
|
||||||
- Renew certificates
|
- Renew certificates
|
||||||
|
@ -24,67 +38,234 @@ Let's Encrypt client and ACME library written in Go.
|
||||||
- Robust implementation of all ACME challenges
|
- Robust implementation of all ACME challenges
|
||||||
- HTTP (http-01)
|
- HTTP (http-01)
|
||||||
- DNS (dns-01)
|
- DNS (dns-01)
|
||||||
- TLS (tls-alpn-01)
|
|
||||||
- SAN certificate support
|
- 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://github.com/xenolf/lego/tree/master/providers/dns)
|
||||||
- Comes with multiple optional [DNS providers](https://go-acme.github.io/lego/dns)
|
- [Custom challenge solvers](https://github.com/xenolf/lego/wiki/Writing-a-Challenge-Solver)
|
||||||
- [Custom challenge solvers](https://go-acme.github.io/lego/usage/library/writing-a-challenge-solver/)
|
|
||||||
- Certificate bundling
|
- Certificate bundling
|
||||||
- OCSP helper function
|
- 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)
|
- Use setcap 'cap_net_bind_service=+ep' /path/to/program
|
||||||
- as a [library](https://go-acme.github.io/lego/usage/library)
|
- 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.
|
||||||
|
|
||||||
| | | | |
|
#### Usage
|
||||||
|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
|
|
||||||
| [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/) | |
|
|
||||||
|
|
||||||
<!-- END DNS PROVIDERS LIST -->
|
```
|
||||||
|
NAME:
|
||||||
|
lego - 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 [global options] command [command options] [arguments...]
|
||||||
|
|
||||||
|
VERSION:
|
||||||
|
0.4.1
|
||||||
|
|
||||||
|
COMMANDS:
|
||||||
|
run Register an account, then create and install a certificate
|
||||||
|
revoke Revoke a certificate
|
||||||
|
renew Renew a certificate
|
||||||
|
dnshelp Shows additional help for the --dns global option
|
||||||
|
help, h Shows a list of commands or help for one command
|
||||||
|
|
||||||
|
GLOBAL OPTIONS:
|
||||||
|
--domains value, -d value Add a domain to the process. Can be specified multiple times.
|
||||||
|
--csr value, -c value Certificate signing request filename, if an external CSR is to be used
|
||||||
|
--server value, -s value CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. (default: "https://acme-v01.api.letsencrypt.org/directory")
|
||||||
|
--email value, -m value Email used for registration and recovery contact.
|
||||||
|
--accept-tos, -a By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service.
|
||||||
|
--key-type value, -k value Key type to use for private keys. Supported: rsa2048, rsa4096, rsa8192, ec256, ec384 (default: "rsa2048")
|
||||||
|
--path value Directory to use for storing the data (default: "/.lego")
|
||||||
|
--exclude value, -x value Explicitly disallow solvers by name from being used. Solvers: "http-01", "dns-01",.
|
||||||
|
--webroot value Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge
|
||||||
|
--memcached-host value Set the memcached host(s) to use for HTTP based challenges. Challenges will be written to all specified hosts.
|
||||||
|
--http value Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port
|
||||||
|
--dns value Solve a DNS challenge using the specified provider. Disables all other challenges. Run 'lego dnshelp' for help on usage.
|
||||||
|
--http-timeout value Set the HTTP timeout value to a specific value in seconds. The default is 10 seconds. (default: 0)
|
||||||
|
--dns-timeout value Set the DNS timeout value to a specific value in seconds. The default is 10 seconds. (default: 0)
|
||||||
|
--dns-resolvers value Set the resolvers to use for performing recursive DNS queries. Supported: host:port. The default is to use the system resolvers, or Google's DNS resolvers if the system's cannot be determined.
|
||||||
|
--pem Generate a .pem file by concatanating the .key and .crt files together.
|
||||||
|
--help, -h show help
|
||||||
|
--version, -v print the version
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 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 --email="foo@bar.com" --domains="example.com" run
|
||||||
|
```
|
||||||
|
|
||||||
|
(Find your certificate in the `.lego` folder of current working directory.)
|
||||||
|
|
||||||
|
To renew the certificate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ lego --email="foo@bar.com" --domains="example.com" renew
|
||||||
|
```
|
||||||
|
|
||||||
|
To renew the certificate only if it's older than 30 days
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ lego --email="foo@bar.com" --domains="example.com" renew --days 30
|
||||||
|
```
|
||||||
|
|
||||||
|
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 --email="foo@bar.com" --domains="example.com" --dns="route53" run
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that `--dns=foo` implies `--exclude=http-01`. lego will not attempt other challenges if you've told it to use DNS instead.
|
||||||
|
|
||||||
|
Obtain a certificate given a certificate signing request (CSR) generated by something else:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ lego --email="foo@bar.com" --csr=/path/to/csr.pem run
|
||||||
|
```
|
||||||
|
|
||||||
|
(lego will infer the domains to be validated based on the contents of the CSR, so make sure the CSR's Common Name and optional SubjectAltNames are set correctly.)
|
||||||
|
|
||||||
|
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/directory", &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, false)
|
||||||
|
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.
|
||||||
|
```
|
||||||
|
|
134
account.go
Normal file
134
account.go
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Account represents a users local saved credentials
|
||||||
|
type Account struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
key crypto.PrivateKey
|
||||||
|
Registration *acme.RegistrationResource `json:"registration"`
|
||||||
|
|
||||||
|
conf *Configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAccount creates a new account for an email address
|
||||||
|
func NewAccount(email string, conf *Configuration) *Account {
|
||||||
|
accKeysPath := conf.AccountKeysPath(email)
|
||||||
|
// TODO: move to function in configuration?
|
||||||
|
accKeyPath := accKeysPath + string(os.PathSeparator) + email + ".key"
|
||||||
|
if err := checkFolder(accKeysPath); err != nil {
|
||||||
|
log.Fatalf("Could not check/create directory for account %s: %v", email, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var privKey crypto.PrivateKey
|
||||||
|
if _, err := os.Stat(accKeyPath); os.IsNotExist(err) {
|
||||||
|
|
||||||
|
log.Printf("No key found for account %s. Generating a curve P384 EC key.", email)
|
||||||
|
privKey, err = generatePrivateKey(accKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not generate RSA private account key for account %s: %v", email, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Saved key to %s", accKeyPath)
|
||||||
|
} else {
|
||||||
|
privKey, err = loadPrivateKey(accKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not load RSA private key from file %s: %v", accKeyPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accountFile := path.Join(conf.AccountPath(email), "account.json")
|
||||||
|
if _, err := os.Stat(accountFile); os.IsNotExist(err) {
|
||||||
|
return &Account{Email: email, key: privKey, conf: conf}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileBytes, err := ioutil.ReadFile(accountFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not load file for account %s -> %v", email, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var acc Account
|
||||||
|
err = json.Unmarshal(fileBytes, &acc)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not parse file for account %s -> %v", email, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.key = privKey
|
||||||
|
acc.conf = conf
|
||||||
|
|
||||||
|
if acc.Registration == nil || acc.Registration.Body.Status == "" {
|
||||||
|
reg, err := tryRecoverAccount(privKey, conf)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not load account for %s. Registration is nil -> %#v", email, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.Registration = reg
|
||||||
|
err = acc.Save()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not save account for %s. Registration is nil -> %#v", email, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if acc.conf == nil {
|
||||||
|
log.Fatalf("Could not load account for %s. Configuration is nil.", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &acc
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryRecoverAccount(privKey crypto.PrivateKey, conf *Configuration) (*acme.RegistrationResource, error) {
|
||||||
|
// couldn't load account but got a key. Try to look the account up.
|
||||||
|
serverURL := conf.context.GlobalString("server")
|
||||||
|
client, err := acme.NewClient(serverURL, &Account{key: privKey, conf: conf}, acme.RSA2048)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, err := client.ResolveAccountByKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Implementation of the acme.User interface **/
|
||||||
|
|
||||||
|
// GetEmail returns the email address for the account
|
||||||
|
func (a *Account) GetEmail() string {
|
||||||
|
return a.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrivateKey returns the private RSA account key.
|
||||||
|
func (a *Account) GetPrivateKey() crypto.PrivateKey {
|
||||||
|
return a.key
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRegistration returns the server registration
|
||||||
|
func (a *Account) GetRegistration() *acme.RegistrationResource {
|
||||||
|
return a.Registration
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End **/
|
||||||
|
|
||||||
|
// Save the account to disk
|
||||||
|
func (a *Account) Save() error {
|
||||||
|
jsonBytes, err := json.MarshalIndent(a, "", "\t")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ioutil.WriteFile(
|
||||||
|
path.Join(a.conf.AccountPath(a.Email), "account.json"),
|
||||||
|
jsonBytes,
|
||||||
|
0600,
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
165
acme/api/api.go
165
acme/api/api.go
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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")
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
)
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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")
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
13
acme/challenges.go
Normal file
13
acme/challenges.go
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
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")
|
||||||
|
// 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")
|
||||||
|
)
|
844
acme/client.go
Normal file
844
acme/client.go
Normal file
|
@ -0,0 +1,844 @@
|
||||||
|
// Package acme implements the ACME protocol for Let's Encrypt and other conforming providers.
|
||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxBodySize is the maximum size of body that we will read.
|
||||||
|
maxBodySize = 1024 * 1024
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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. A private
|
||||||
|
// key of type keyType (see KeyType contants) will be generated when requesting a new
|
||||||
|
// certificate if one isn't provided.
|
||||||
|
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.NewAccountURL == "" {
|
||||||
|
return nil, errors.New("directory missing new registration URL")
|
||||||
|
}
|
||||||
|
if dir.NewOrderURL == "" {
|
||||||
|
return nil, errors.New("directory missing new order URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
jws := &jws{privKey: privKey, getNonceURL: dir.NewNonceURL}
|
||||||
|
if reg := user.GetRegistration(); reg != nil {
|
||||||
|
jws.kid = reg.URI
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{}}
|
||||||
|
|
||||||
|
return &Client{directory: dir, user: user, jws: jws, keyType: keyType, solvers: solvers}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChallengeProvider specifies a custom provider p that can solve the given challenge type.
|
||||||
|
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 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.
|
||||||
|
//
|
||||||
|
// NOTE: This REPLACES any custom HTTP provider previously set by calling
|
||||||
|
// c.SetChallengeProvider with the default HTTP challenge provider.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetToSURL returns the current ToS URL from the Directory
|
||||||
|
func (c *Client) GetToSURL() string {
|
||||||
|
return c.directory.Meta.TermsOfService
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExternalAccountRequired returns the External Account Binding requirement of the Directory
|
||||||
|
func (c *Client) GetExternalAccountRequired() bool {
|
||||||
|
return c.directory.Meta.ExternalAccountRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the current account to the ACME server.
|
||||||
|
func (c *Client) Register(tosAgreed bool) (*RegistrationResource, error) {
|
||||||
|
if c == nil || c.user == nil {
|
||||||
|
return nil, errors.New("acme: cannot register a nil client or user")
|
||||||
|
}
|
||||||
|
log.Printf("[INFO] acme: Registering account for %s", c.user.GetEmail())
|
||||||
|
|
||||||
|
accMsg := accountMessage{}
|
||||||
|
if c.user.GetEmail() != "" {
|
||||||
|
accMsg.Contact = []string{"mailto:" + c.user.GetEmail()}
|
||||||
|
} else {
|
||||||
|
accMsg.Contact = []string{}
|
||||||
|
}
|
||||||
|
accMsg.TermsOfServiceAgreed = tosAgreed
|
||||||
|
|
||||||
|
var serverReg accountMessage
|
||||||
|
hdr, err := postJSON(c.jws, c.directory.NewAccountURL, accMsg, &serverReg)
|
||||||
|
if err != nil {
|
||||||
|
remoteErr, ok := err.(RemoteError)
|
||||||
|
if ok && remoteErr.StatusCode == 409 {
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := &RegistrationResource{
|
||||||
|
URI: hdr.Get("Location"),
|
||||||
|
Body: serverReg,
|
||||||
|
}
|
||||||
|
c.jws.kid = reg.URI
|
||||||
|
|
||||||
|
return reg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the current account to the ACME server.
|
||||||
|
func (c *Client) RegisterWithExternalAccountBinding(tosAgreed bool, kid string, hmacEncoded string) (*RegistrationResource, error) {
|
||||||
|
if c == nil || c.user == nil {
|
||||||
|
return nil, errors.New("acme: cannot register a nil client or user")
|
||||||
|
}
|
||||||
|
log.Printf("[INFO] acme: Registering account (EAB) for %s", c.user.GetEmail())
|
||||||
|
|
||||||
|
accMsg := accountMessage{}
|
||||||
|
if c.user.GetEmail() != "" {
|
||||||
|
accMsg.Contact = []string{"mailto:" + c.user.GetEmail()}
|
||||||
|
} else {
|
||||||
|
accMsg.Contact = []string{}
|
||||||
|
}
|
||||||
|
accMsg.TermsOfServiceAgreed = tosAgreed
|
||||||
|
|
||||||
|
hmac, err := base64.RawURLEncoding.DecodeString(hmacEncoded)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("acme: could not decode hmac key: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
eabJWS, err := c.jws.signEABContent(c.directory.NewAccountURL, kid, hmac)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("acme: error signing eab content: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
eabPayload := eabJWS.FullSerialize()
|
||||||
|
|
||||||
|
accMsg.ExternalAccountBinding = []byte(eabPayload)
|
||||||
|
|
||||||
|
var serverReg accountMessage
|
||||||
|
hdr, err := postJSON(c.jws, c.directory.NewAccountURL, accMsg, &serverReg)
|
||||||
|
if err != nil {
|
||||||
|
remoteErr, ok := err.(RemoteError)
|
||||||
|
if ok && remoteErr.StatusCode == 409 {
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := &RegistrationResource{
|
||||||
|
URI: hdr.Get("Location"),
|
||||||
|
Body: serverReg,
|
||||||
|
}
|
||||||
|
c.jws.kid = reg.URI
|
||||||
|
|
||||||
|
return reg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveAccountByKey will attempt to look up an account using the given account key
|
||||||
|
// and return its registration resource.
|
||||||
|
func (c *Client) ResolveAccountByKey() (*RegistrationResource, error) {
|
||||||
|
log.Printf("[INFO] acme: Trying to resolve account by key")
|
||||||
|
|
||||||
|
acc := accountMessage{OnlyReturnExisting: true}
|
||||||
|
hdr, err := postJSON(c.jws, c.directory.NewAccountURL, acc, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accountLink := hdr.Get("Location")
|
||||||
|
if accountLink == "" {
|
||||||
|
return nil, errors.New("Server did not return the account link")
|
||||||
|
}
|
||||||
|
|
||||||
|
var retAccount accountMessage
|
||||||
|
c.jws.kid = accountLink
|
||||||
|
_, err = postJSON(c.jws, accountLink, accountMessage{}, &retAccount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RegistrationResource{URI: accountLink, Body: retAccount}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRegistration deletes the client's user registration from the ACME
|
||||||
|
// server.
|
||||||
|
func (c *Client) DeleteRegistration() error {
|
||||||
|
if c == nil || c.user == nil {
|
||||||
|
return errors.New("acme: cannot unregister a nil client or user")
|
||||||
|
}
|
||||||
|
log.Printf("[INFO] acme: Deleting account for %s", c.user.GetEmail())
|
||||||
|
|
||||||
|
accMsg := accountMessage{
|
||||||
|
Status: "deactivated",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := postJSON(c.jws, c.user.GetRegistration().URI, accMsg, nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryRegistration runs a POST request on the client's registration and
|
||||||
|
// returns the result.
|
||||||
|
//
|
||||||
|
// This is similar to the Register function, but acting on an existing
|
||||||
|
// registration link and resource.
|
||||||
|
func (c *Client) QueryRegistration() (*RegistrationResource, error) {
|
||||||
|
if c == nil || c.user == nil {
|
||||||
|
return nil, errors.New("acme: cannot query the registration of a nil client or user")
|
||||||
|
}
|
||||||
|
// Log the URL here instead of the email as the email may not be set
|
||||||
|
log.Printf("[INFO] acme: Querying account for %s", c.user.GetRegistration().URI)
|
||||||
|
|
||||||
|
accMsg := accountMessage{}
|
||||||
|
|
||||||
|
var serverReg accountMessage
|
||||||
|
_, err := postJSON(c.jws, c.user.GetRegistration().URI, accMsg, &serverReg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := &RegistrationResource{Body: serverReg}
|
||||||
|
|
||||||
|
// Location: header is not returned so this needs to be populated off of
|
||||||
|
// existing URI
|
||||||
|
reg.URI = c.user.GetRegistration().URI
|
||||||
|
|
||||||
|
return reg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObtainCertificateForCSR 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 *Client) ObtainCertificateForCSR(csr x509.CertificateRequest, bundle bool) (*CertificateResource, error) {
|
||||||
|
// figure out what domains it concerns
|
||||||
|
// start with the common name
|
||||||
|
domains := []string{csr.Subject.CommonName}
|
||||||
|
|
||||||
|
// loop over the SubjectAltName DNS names
|
||||||
|
DNSNames:
|
||||||
|
for _, sanName := range csr.DNSNames {
|
||||||
|
for _, existingName := range domains {
|
||||||
|
if existingName == sanName {
|
||||||
|
// duplicate; skip this name
|
||||||
|
continue DNSNames
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// name is unique
|
||||||
|
domains = append(domains, sanName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bundle {
|
||||||
|
log.Printf("[INFO][%s] acme: Obtaining bundled SAN certificate given a CSR", strings.Join(domains, ", "))
|
||||||
|
} else {
|
||||||
|
log.Printf("[INFO][%s] acme: Obtaining SAN certificate given a CSR", strings.Join(domains, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := c.createOrderForIdentifiers(domains)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
authz, err := c.getAuthzForOrder(order)
|
||||||
|
if err != nil {
|
||||||
|
// If any challenge fails, return. Do not generate partial SAN certificates.
|
||||||
|
/*for _, auth := range authz {
|
||||||
|
c.disableAuthz(auth)
|
||||||
|
}*/
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.solveChallengeForAuthz(authz)
|
||||||
|
if err != nil {
|
||||||
|
// If any challenge fails, return. Do not generate partial SAN certificates.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[INFO][%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", "))
|
||||||
|
|
||||||
|
failures := make(ObtainError)
|
||||||
|
cert, err := c.requestCertificateForCsr(order, bundle, csr.Raw, nil)
|
||||||
|
if err != nil {
|
||||||
|
for _, chln := range authz {
|
||||||
|
failures[chln.Identifier.Value] = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the CSR to the certificate so that it can be used for renewals.
|
||||||
|
cert.CSR = pemEncode(&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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, mustStaple bool) (*CertificateResource, error) {
|
||||||
|
if len(domains) == 0 {
|
||||||
|
return nil, errors.New("No domains to obtain a certificate for")
|
||||||
|
}
|
||||||
|
|
||||||
|
if bundle {
|
||||||
|
log.Printf("[INFO][%s] acme: Obtaining bundled SAN certificate", strings.Join(domains, ", "))
|
||||||
|
} else {
|
||||||
|
log.Printf("[INFO][%s] acme: Obtaining SAN certificate", strings.Join(domains, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := c.createOrderForIdentifiers(domains)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
authz, err := c.getAuthzForOrder(order)
|
||||||
|
if err != nil {
|
||||||
|
// If any challenge fails, return. Do not generate partial SAN certificates.
|
||||||
|
/*for _, auth := range authz {
|
||||||
|
c.disableAuthz(auth)
|
||||||
|
}*/
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.solveChallengeForAuthz(authz)
|
||||||
|
if err != nil {
|
||||||
|
// If any challenge fails, return. Do not generate partial SAN certificates.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[INFO][%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", "))
|
||||||
|
|
||||||
|
failures := make(ObtainError)
|
||||||
|
cert, err := c.requestCertificateForOrder(order, bundle, privKey, mustStaple)
|
||||||
|
if err != nil {
|
||||||
|
for _, auth := range authz {
|
||||||
|
failures[auth.Identifier.Value] = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{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, mustStaple 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 nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
x509Cert := certificates[0]
|
||||||
|
if x509Cert.IsCA {
|
||||||
|
return nil, 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())
|
||||||
|
log.Printf("[INFO][%s] acme: Trying renewal with %d hours remaining", cert.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(cert.CSR) > 0 {
|
||||||
|
csr, err := pemDecodeTox509CSR(cert.CSR)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
newCert, failures := c.ObtainCertificateForCSR(*csr, bundle)
|
||||||
|
return newCert, failures
|
||||||
|
}
|
||||||
|
|
||||||
|
var privKey crypto.PrivateKey
|
||||||
|
if cert.PrivateKey != nil {
|
||||||
|
privKey, err = parsePEMPrivateKey(cert.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var domains []string
|
||||||
|
// 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, err := c.ObtainCertificate(domains, bundle, privKey, mustStaple)
|
||||||
|
return newCert, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) createOrderForIdentifiers(domains []string) (orderResource, error) {
|
||||||
|
|
||||||
|
var identifiers []identifier
|
||||||
|
for _, domain := range domains {
|
||||||
|
identifiers = append(identifiers, identifier{Type: "dns", Value: domain})
|
||||||
|
}
|
||||||
|
|
||||||
|
order := orderMessage{
|
||||||
|
Identifiers: identifiers,
|
||||||
|
}
|
||||||
|
|
||||||
|
var response orderMessage
|
||||||
|
hdr, err := postJSON(c.jws, c.directory.NewOrderURL, order, &response)
|
||||||
|
if err != nil {
|
||||||
|
return orderResource{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
orderRes := orderResource{
|
||||||
|
URL: hdr.Get("Location"),
|
||||||
|
Domains: domains,
|
||||||
|
orderMessage: response,
|
||||||
|
}
|
||||||
|
return orderRes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looks through the challenge combinations to find a solvable match.
|
||||||
|
// Then solves the challenges in series and returns.
|
||||||
|
func (c *Client) solveChallengeForAuthz(authorizations []authorization) error {
|
||||||
|
failures := make(ObtainError)
|
||||||
|
|
||||||
|
// loop through the resources, basically through the domains.
|
||||||
|
for _, authz := range authorizations {
|
||||||
|
if authz.Status == "valid" {
|
||||||
|
// Boulder might recycle recent validated authz (see issue #267)
|
||||||
|
log.Printf("[INFO][%s] acme: Authorization already valid; skipping challenge", authz.Identifier.Value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// no solvers - no solving
|
||||||
|
if i, solver := c.chooseSolver(authz, authz.Identifier.Value); solver != nil {
|
||||||
|
err := solver.Solve(authz.Challenges[i], authz.Identifier.Value)
|
||||||
|
if err != nil {
|
||||||
|
//c.disableAuthz(authz.Identifier)
|
||||||
|
failures[authz.Identifier.Value] = err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
//c.disableAuthz(authz)
|
||||||
|
failures[authz.Identifier.Value] = fmt.Errorf("[%s] acme: Could not determine solvers", authz.Identifier.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks all challenges from the server in order and returns the first matching solver.
|
||||||
|
func (c *Client) chooseSolver(auth authorization, domain string) (int, solver) {
|
||||||
|
for i, challenge := range auth.Challenges {
|
||||||
|
if solver, ok := c.solvers[Challenge(challenge.Type)]; ok {
|
||||||
|
return i, solver
|
||||||
|
}
|
||||||
|
log.Printf("[INFO][%s] acme: Could not find solver for: %s", domain, challenge.Type)
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the challenges needed to proof our identifier to the ACME server.
|
||||||
|
func (c *Client) getAuthzForOrder(order orderResource) ([]authorization, error) {
|
||||||
|
resc, errc := make(chan authorization), make(chan domainError)
|
||||||
|
|
||||||
|
delay := time.Second / overallRequestLimit
|
||||||
|
|
||||||
|
for _, authzURL := range order.Authorizations {
|
||||||
|
time.Sleep(delay)
|
||||||
|
|
||||||
|
go func(authzURL string) {
|
||||||
|
var authz authorization
|
||||||
|
_, err := getJSON(authzURL, &authz)
|
||||||
|
if err != nil {
|
||||||
|
errc <- domainError{Domain: authz.Identifier.Value, Error: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resc <- authz
|
||||||
|
}(authzURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logAuthz(order)
|
||||||
|
|
||||||
|
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 logAuthz(order orderResource) {
|
||||||
|
for i, auth := range order.Authorizations {
|
||||||
|
log.Printf("[INFO][%s] AuthURL: %s", order.Identifiers[i].Value, auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanAuthz loops through the passed in slice and disables any auths which are not "valid"
|
||||||
|
func (c *Client) disableAuthz(authURL string) error {
|
||||||
|
var disabledAuth authorization
|
||||||
|
_, err := postJSON(c.jws, authURL, deactivateAuthMessage{Status: "deactivated"}, &disabledAuth)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) requestCertificateForOrder(order orderResource, bundle bool, privKey crypto.PrivateKey, mustStaple bool) (*CertificateResource, error) {
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if privKey == nil {
|
||||||
|
privKey, err = generatePrivateKey(c.keyType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// determine certificate name(s) based on the authorization resources
|
||||||
|
commonName := order.Domains[0]
|
||||||
|
var san []string
|
||||||
|
for _, auth := range order.Identifiers {
|
||||||
|
san = append(san, auth.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: should the CSR be customizable?
|
||||||
|
csr, err := generateCsr(privKey, commonName, san, mustStaple)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.requestCertificateForCsr(order, bundle, csr, pemEncode(privKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) requestCertificateForCsr(order orderResource, bundle bool, csr []byte, privateKeyPem []byte) (*CertificateResource, error) {
|
||||||
|
commonName := order.Domains[0]
|
||||||
|
|
||||||
|
csrString := base64.RawURLEncoding.EncodeToString(csr)
|
||||||
|
var retOrder orderMessage
|
||||||
|
_, error := postJSON(c.jws, order.Finalize, csrMessage{Csr: csrString}, &retOrder)
|
||||||
|
if error != nil {
|
||||||
|
return nil, error
|
||||||
|
}
|
||||||
|
|
||||||
|
if retOrder.Status == "invalid" {
|
||||||
|
return nil, error
|
||||||
|
}
|
||||||
|
|
||||||
|
certRes := CertificateResource{
|
||||||
|
Domain: commonName,
|
||||||
|
CertURL: retOrder.Certificate,
|
||||||
|
PrivateKey: privateKeyPem,
|
||||||
|
}
|
||||||
|
|
||||||
|
if retOrder.Status == "valid" {
|
||||||
|
// if the certificate is available right away, short cut!
|
||||||
|
ok, err := c.checkCertResponse(retOrder, &certRes, bundle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
return &certRes, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxChecks := 1000
|
||||||
|
for i := 0; i < maxChecks; i++ {
|
||||||
|
_, err := getJSON(order.URL, &retOrder)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
done, err := c.checkCertResponse(retOrder, &certRes, bundle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if done {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if i == maxChecks-1 {
|
||||||
|
return nil, fmt.Errorf("polled for certificate %d times; giving up", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &certRes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkCertResponse 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 *Client) checkCertResponse(order orderMessage, certRes *CertificateResource, bundle bool) (bool, error) {
|
||||||
|
|
||||||
|
switch order.Status {
|
||||||
|
case "valid":
|
||||||
|
resp, err := httpGet(order.Certificate)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := ioutil.ReadAll(limitReader(resp.Body, maxBodySize))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The issuer certificate link is always supplied via an "up" link
|
||||||
|
// in the response headers of a new certificate.
|
||||||
|
links := parseLinks(resp.Header["Link"])
|
||||||
|
if link, ok := links["up"]; ok {
|
||||||
|
issuerCert, err := c.getIssuerCertificate(link)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// If we fail to acquire the issuer cert, return the issued certificate - do not fail.
|
||||||
|
log.Printf("[WARNING][%s] acme: Could not bundle issuer certificate: %v", certRes.Domain, err)
|
||||||
|
} else {
|
||||||
|
issuerCert = pemEncode(derCertificateBytes(issuerCert))
|
||||||
|
|
||||||
|
// 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, issuerCert...)
|
||||||
|
}
|
||||||
|
|
||||||
|
certRes.IssuerCertificate = issuerCert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
certRes.Certificate = cert
|
||||||
|
certRes.CertURL = order.Certificate
|
||||||
|
certRes.CertStableURL = order.Certificate
|
||||||
|
log.Printf("[INFO][%s] Server responded with a certificate.", certRes.Domain)
|
||||||
|
return true, nil
|
||||||
|
|
||||||
|
case "processing":
|
||||||
|
return false, nil
|
||||||
|
case "invalid":
|
||||||
|
return false, errors.New("Order has invalid state: invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getIssuerCertificate requests the issuer certificate
|
||||||
|
func (c *Client) getIssuerCertificate(url string) ([]byte, error) {
|
||||||
|
log.Printf("[INFO] acme: Requesting issuer cert from %s", url)
|
||||||
|
resp, err := httpGet(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
issuerBytes, err := ioutil.ReadAll(limitReader(resp.Body, maxBodySize))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = x509.ParseCertificate(issuerBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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, c challenge) error {
|
||||||
|
var chlng challenge
|
||||||
|
|
||||||
|
hdr, err := postJSON(j, uri, c, &chlng)
|
||||||
|
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 chlng.Status {
|
||||||
|
case "valid":
|
||||||
|
log.Printf("[INFO][%s] The server validated our request", domain)
|
||||||
|
return nil
|
||||||
|
case "pending":
|
||||||
|
case "invalid":
|
||||||
|
return handleChallengeError(chlng)
|
||||||
|
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 = 5
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(ra) * time.Second)
|
||||||
|
|
||||||
|
hdr, err = getJSON(uri, &chlng)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
316
acme/client_test.go
Normal file
316
acme/client_test.go
Normal file
|
@ -0,0 +1,316 @@
|
||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
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{
|
||||||
|
NewNonceURL: "http://test",
|
||||||
|
NewAccountURL: "http://test",
|
||||||
|
NewOrderURL: "http://test",
|
||||||
|
RevokeCertURL: "http://test",
|
||||||
|
KeyChangeURL: "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 := 1, 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{
|
||||||
|
NewNonceURL: "http://test",
|
||||||
|
NewAccountURL: "http://test",
|
||||||
|
NewOrderURL: "http://test",
|
||||||
|
RevokeCertURL: "http://test",
|
||||||
|
KeyChangeURL: "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))
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// test setting different host
|
||||||
|
optHost = "127.0.0.1"
|
||||||
|
client.SetHTTPAddress(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotHoldingLockWhileMakingHTTPRequests(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
w.Header().Add("Replay-Nonce", "12345")
|
||||||
|
w.Header().Add("Retry-After", "0")
|
||||||
|
writeJSONResponse(w, &challenge{Type: "http-01", Status: "Valid", URL: "http://example.com/", Token: "token"})
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
privKey, _ := rsa.GenerateKey(rand.Reader, 512)
|
||||||
|
j := &jws{privKey: privKey, getNonceURL: ts.URL}
|
||||||
|
ch := make(chan bool)
|
||||||
|
resultCh := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
j.Nonce()
|
||||||
|
ch <- true
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
j.Nonce()
|
||||||
|
ch <- true
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
<-ch
|
||||||
|
<-ch
|
||||||
|
resultCh <- true
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-resultCh:
|
||||||
|
case <-time.After(400 * time.Millisecond):
|
||||||
|
t.Fatal("JWS is probably holding a lock while making HTTP request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, URL: "http://example.com/", Token: "token"})
|
||||||
|
|
||||||
|
case "GET":
|
||||||
|
st := statuses[0]
|
||||||
|
statuses = statuses[1:]
|
||||||
|
writeJSONResponse(w, &challenge{Type: "http-01", Status: st, URL: "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, getNonceURL: ts.URL}
|
||||||
|
|
||||||
|
tsts := []struct {
|
||||||
|
name string
|
||||||
|
statuses []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"POST-unexpected", []string{"weird"}, "unexpected"},
|
||||||
|
{"POST-valid", []string{"valid"}, ""},
|
||||||
|
{"POST-invalid", []string{"invalid"}, "Error"},
|
||||||
|
{"GET-unexpected", []string{"pending", "weird"}, "unexpected"},
|
||||||
|
{"GET-valid", []string{"pending", "valid"}, ""},
|
||||||
|
{"GET-invalid", []string{"pending", "invalid"}, "Error"},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetChallenges(t *testing.T) {
|
||||||
|
var ts *httptest.Server
|
||||||
|
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case "GET", "HEAD":
|
||||||
|
w.Header().Add("Replay-Nonce", "12345")
|
||||||
|
w.Header().Add("Retry-After", "0")
|
||||||
|
writeJSONResponse(w, directory{
|
||||||
|
NewNonceURL: ts.URL,
|
||||||
|
NewAccountURL: ts.URL,
|
||||||
|
NewOrderURL: ts.URL,
|
||||||
|
RevokeCertURL: ts.URL,
|
||||||
|
KeyChangeURL: ts.URL,
|
||||||
|
})
|
||||||
|
case "POST":
|
||||||
|
writeJSONResponse(w, orderMessage{})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
keyBits := 512 // 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: &RegistrationResource{URI: ts.URL},
|
||||||
|
privatekey: key,
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := NewClient(ts.URL, user, keyType)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Could not create client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.createOrderForIdentifiers([]string{"example.com"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("Expecting \"Server did not provide next link to proceed\" error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAccountByKey(t *testing.T) {
|
||||||
|
keyBits := 512
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
var ts *httptest.Server
|
||||||
|
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.RequestURI {
|
||||||
|
case "/directory":
|
||||||
|
writeJSONResponse(w, directory{
|
||||||
|
NewNonceURL: ts.URL + "/nonce",
|
||||||
|
NewAccountURL: ts.URL + "/account",
|
||||||
|
NewOrderURL: ts.URL + "/newOrder",
|
||||||
|
RevokeCertURL: ts.URL + "/revokeCert",
|
||||||
|
KeyChangeURL: ts.URL + "/keyChange",
|
||||||
|
})
|
||||||
|
case "/nonce":
|
||||||
|
w.Header().Add("Replay-Nonce", "12345")
|
||||||
|
w.Header().Add("Retry-After", "0")
|
||||||
|
case "/account":
|
||||||
|
w.Header().Set("Location", ts.URL+"/account_recovery")
|
||||||
|
case "/account_recovery":
|
||||||
|
writeJSONResponse(w, accountMessage{
|
||||||
|
Status: "valid",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
client, err := NewClient(ts.URL+"/directory", user, keyType)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Could not create client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res, err := client.ResolveAccountByKey(); err != nil {
|
||||||
|
t.Fatalf("Unexpected error resolving account by key: %v", err)
|
||||||
|
} else if res.Body.Status != "valid" {
|
||||||
|
t.Errorf("Unexpected account status: %v", res.Body.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }
|
341
acme/commons.go
341
acme/commons.go
|
@ -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"`
|
|
||||||
}
|
|
344
acme/crypto.go
Normal file
344
acme/crypto.go
Normal file
|
@ -0,0 +1,344 @@
|
||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ocsp"
|
||||||
|
jose "gopkg.in/square/go-jose.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ocspRes, err := ocsp.ParseResponse(ocspResBytes, 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 := &jose.JSONWebKey{Key: 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.RawURLEncoding.EncodeToString(thumbBytes)
|
||||||
|
|
||||||
|
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, mustStaple bool) ([]byte, error) {
|
||||||
|
template := x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: domain,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(san) > 0 {
|
||||||
|
template.DNSNames = san
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pemDecodeTox509CSR(pem []byte) (*x509.CertificateRequest, error) {
|
||||||
|
pemBlock, err := pemDecode(pem)
|
||||||
|
if pemBlock == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if pemBlock.Type != "CERTIFICATE REQUEST" {
|
||||||
|
return nil, fmt.Errorf("PEM block is not a certificate request")
|
||||||
|
}
|
||||||
|
|
||||||
|
return x509.ParseCertificateRequest(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
93
acme/crypto_test.go
Normal 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, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("Error generating CSR:", err)
|
||||||
|
}
|
||||||
|
if 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)
|
||||||
|
}
|
309
acme/dns_challenge.go
Normal file
309
acme/dns_challenge.go
Normal file
|
@ -0,0 +1,309 @@
|
||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type preCheckDNSFunc func(fqdn, value string) (bool, error)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// PreCheckDNS checks DNS propagation before notifying ACME that
|
||||||
|
// the DNS challenge is ready.
|
||||||
|
PreCheckDNS preCheckDNSFunc = checkDNSPropagation
|
||||||
|
fqdnToZone = map[string]string{}
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultResolvConf = "/etc/resolv.conf"
|
||||||
|
|
||||||
|
var defaultNameservers = []string{
|
||||||
|
"google-public-dns-a.google.com:53",
|
||||||
|
"google-public-dns-b.google.com:53",
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecursiveNameservers are used to pre-check DNS propagations
|
||||||
|
var RecursiveNameservers = getNameservers(defaultResolvConf, defaultNameservers)
|
||||||
|
|
||||||
|
// DNSTimeout is used to override the default DNS timeout of 10 seconds.
|
||||||
|
var DNSTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
systemNameservers := []string{}
|
||||||
|
for _, server := range config.Servers {
|
||||||
|
// ensure all servers have a port number
|
||||||
|
if _, _, err := net.SplitHostPort(server); err != nil {
|
||||||
|
systemNameservers = append(systemNameservers, net.JoinHostPort(server, "53"))
|
||||||
|
} else {
|
||||||
|
systemNameservers = append(systemNameservers, server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return systemNameservers
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
value = base64.RawURLEncoding.EncodeToString(keyAuthShaBytes[:sha256.Size])
|
||||||
|
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 {
|
||||||
|
log.Printf("[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)
|
||||||
|
|
||||||
|
log.Printf("[INFO][%s] Checking DNS record propagation using %+v", domain, RecursiveNameservers)
|
||||||
|
|
||||||
|
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.URL, 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, RecursiveNameservers, 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, []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 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 will query a nameserver, iterating through the supplied servers as it retries
|
||||||
|
// The nameserver should include a port, to facilitate testing where we talk to a mock dns server.
|
||||||
|
func dnsQuery(fqdn string, rtype uint16, nameservers []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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Will retry the request based on the number of servers (n+1)
|
||||||
|
for i := 1; i <= len(nameservers)+1; i++ {
|
||||||
|
ns := nameservers[i%len(nameservers)]
|
||||||
|
udp := &dns.Client{Net: "udp", Timeout: DNSTimeout}
|
||||||
|
in, _, err = udp.Exchange(m, ns)
|
||||||
|
|
||||||
|
if err == dns.ErrTruncated {
|
||||||
|
tcp := &dns.Client{Net: "tcp", Timeout: DNSTimeout}
|
||||||
|
// If the TCP request succeeds, the err will reset to nil
|
||||||
|
in, _, err = tcp.Exchange(m, ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupNameservers returns the authoritative nameservers for the given fqdn.
|
||||||
|
func lookupNameservers(fqdn string) ([]string, error) {
|
||||||
|
var authoritativeNss []string
|
||||||
|
|
||||||
|
zone, err := FindZoneByFqdn(fqdn, RecursiveNameservers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Could not determine the zone: %v", 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, fmt.Errorf("Could not determine authoritative nameservers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, nameservers []string) (string, error) {
|
||||||
|
// Do we have it cached?
|
||||||
|
if zone, ok := fqdnToZone[fqdn]; ok {
|
||||||
|
return zone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
labelIndexes := dns.Split(fqdn)
|
||||||
|
for _, index := range labelIndexes {
|
||||||
|
domain := fqdn[index:]
|
||||||
|
|
||||||
|
in, err := dnsQuery(domain, dns.TypeSOA, nameservers, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any response code other than NOERROR and NXDOMAIN is treated as error
|
||||||
|
if in.Rcode != dns.RcodeNameError && in.Rcode != dns.RcodeSuccess {
|
||||||
|
return "", fmt.Errorf("Unexpected response code '%s' for %s",
|
||||||
|
dns.RcodeToString[in.Rcode], domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we got a SOA RR in the answer section
|
||||||
|
if in.Rcode == dns.RcodeSuccess {
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
zone := soa.Hdr.Name
|
||||||
|
fqdnToZone[fqdn] = zone
|
||||||
|
return zone, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("Could not find the start of authority")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
55
acme/dns_challenge_manual.go
Normal file
55
acme/dns_challenge_manual.go
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
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, RecursiveNameservers)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[INFO] acme: Please create the following TXT record in your %s zone:", authZone)
|
||||||
|
log.Printf("[INFO] acme: %s", dnsRecord)
|
||||||
|
log.Printf("[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, RecursiveNameservers)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[INFO] acme: You can now remove this TXT record from your %s zone:", authZone)
|
||||||
|
log.Printf("[INFO] acme: %s", dnsRecord)
|
||||||
|
return nil
|
||||||
|
}
|
200
acme/dns_challenge_test.go
Normal file
200
acme/dns_challenge_test.go
Normal file
|
@ -0,0 +1,200 @@
|
||||||
|
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 the zone",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var findZoneByFqdnTests = []struct {
|
||||||
|
fqdn string
|
||||||
|
zone string
|
||||||
|
}{
|
||||||
|
{"mail.google.com.", "google.com."}, // domain is a CNAME
|
||||||
|
{"foo.google.com.", "google.com."}, // domain is a non-existent subdomain
|
||||||
|
{"example.com.ac.", "ac."}, // domain is a eTLD
|
||||||
|
{"cross-zone-example.assets.sh.", "assets.sh."}, // domain is a cross-zone CNAME
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkResolvConfServersTests = []struct {
|
||||||
|
fixture string
|
||||||
|
expected []string
|
||||||
|
defaults []string
|
||||||
|
}{
|
||||||
|
{"testdata/resolv.conf.1", []string{"10.200.3.249:53", "10.200.3.250:5353", "[2001:4860:4860::8844]:53", "[10.0.0.1]:5353"}, []string{"127.0.0.1:53"}},
|
||||||
|
{"testdata/resolv.conf.nonexistant", []string{"127.0.0.1:53"}, []string{"127.0.0.1:53"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
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, getNonceURL: ts.URL}
|
||||||
|
solver := &dnsChallenge{jws: jws, validate: validate, provider: manualProvider}
|
||||||
|
clientChallenge := challenge{Type: "dns01", Status: "pending", URL: 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 TestFindZoneByFqdn(t *testing.T) {
|
||||||
|
for _, tt := range findZoneByFqdnTests {
|
||||||
|
res, err := FindZoneByFqdn(tt.fqdn, RecursiveNameservers)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("FindZoneByFqdn failed for %s: %v", tt.fqdn, err)
|
||||||
|
}
|
||||||
|
if res != tt.zone {
|
||||||
|
t.Errorf("%s: got %s; want %s", tt.fqdn, res, tt.zone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveConfServers(t *testing.T) {
|
||||||
|
for _, tt := range checkResolvConfServersTests {
|
||||||
|
result := getNameservers(tt.fixture, tt.defaults)
|
||||||
|
|
||||||
|
sort.Strings(result)
|
||||||
|
sort.Strings(tt.expected)
|
||||||
|
if !reflect.DeepEqual(result, tt.expected) {
|
||||||
|
t.Errorf("#%s: expected %q; got %q", tt.fixture, tt.expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
91
acme/error.go
Normal file
91
acme/error.go
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tosAgreementError = "Terms of service have changed"
|
||||||
|
invalidNonceError = "urn:ietf:params:acme:error:badNonce"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// NonceError represents the error which is returned if the
|
||||||
|
// nonce sent by the client was not accepted by the server.
|
||||||
|
type NonceError struct {
|
||||||
|
RemoteError
|
||||||
|
}
|
||||||
|
|
||||||
|
type domainError struct {
|
||||||
|
Domain string
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObtainError is returned when there are specific errors available
|
||||||
|
// per domain. For example in ObtainCertificate
|
||||||
|
type ObtainError map[string]error
|
||||||
|
|
||||||
|
func (e ObtainError) Error() string {
|
||||||
|
buffer := bytes.NewBufferString("acme: Error -> One or more domains had a problem:\n")
|
||||||
|
for dom, err := range e {
|
||||||
|
buffer.WriteString(fmt.Sprintf("[%s] %s\n", dom, err))
|
||||||
|
}
|
||||||
|
return buffer.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleHTTPError(resp *http.Response) error {
|
||||||
|
var errorDetail RemoteError
|
||||||
|
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if contentType == "application/json" || strings.HasPrefix(contentType, "application/problem+json") {
|
||||||
|
err := json.NewDecoder(resp.Body).Decode(&errorDetail)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
detailBytes, err := ioutil.ReadAll(limitReader(resp.Body, maxBodySize))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
errorDetail.Detail = string(detailBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
errorDetail.StatusCode = resp.StatusCode
|
||||||
|
|
||||||
|
// Check for errors we handle specifically
|
||||||
|
if errorDetail.StatusCode == http.StatusForbidden && errorDetail.Detail == tosAgreementError {
|
||||||
|
return TOSError{errorDetail}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errorDetail.StatusCode == http.StatusBadRequest && errorDetail.Type == invalidNonceError {
|
||||||
|
return NonceError{errorDetail}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleChallengeError(chlng challenge) error {
|
||||||
|
return chlng.Error
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
160
acme/http.go
Normal file
160
acme/http.go
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserAgent (if non-empty) will be tacked onto the User-Agent string in requests.
|
||||||
|
var UserAgent string
|
||||||
|
|
||||||
|
// HTTPClient is an HTTP client with a reasonable timeout value.
|
||||||
|
var HTTPClient = http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
Dial: (&net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).Dial,
|
||||||
|
TLSHandshakeTimeout: 15 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 15 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * 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, fmt.Errorf("failed to head %q: %v", url, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", userAgent())
|
||||||
|
|
||||||
|
resp, err = HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("failed to do head %q: %v", url, 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, fmt.Errorf("failed to post %q: %v", url, err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", bodyType)
|
||||||
|
req.Header.Set("User-Agent", userAgent())
|
||||||
|
|
||||||
|
return HTTPClient.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, fmt.Errorf("failed to get %q: %v", url, err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", userAgent())
|
||||||
|
|
||||||
|
return HTTPClient.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 json %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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|
||||||
|
err := handleHTTPError(resp)
|
||||||
|
|
||||||
|
switch err.(type) {
|
||||||
|
|
||||||
|
case NonceError:
|
||||||
|
|
||||||
|
// Retry once if the nonce was invalidated
|
||||||
|
|
||||||
|
retryResp, err := j.post(uri, jsonBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed to post JWS message. -> %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer retryResp.Body.Close()
|
||||||
|
|
||||||
|
if retryResp.StatusCode >= http.StatusBadRequest {
|
||||||
|
return retryResp.Header, handleHTTPError(retryResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if respBody == nil {
|
||||||
|
return retryResp.Header, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return retryResp.Header, json.NewDecoder(retryResp.Body).Decode(respBody)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return resp.Header, err
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
42
acme/http_challenge.go
Normal file
42
acme/http_challenge.go
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/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 {
|
||||||
|
|
||||||
|
log.Printf("[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.URL, challenge{Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth})
|
||||||
|
}
|
81
acme/http_challenge_server.go
Normal file
81
acme/http_challenge_server.go
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
log.Printf("[INFO][%s] Served key authentication", domain)
|
||||||
|
} else {
|
||||||
|
log.Printf("[WARN] Received request for domain %s with method %s but the domain did not match any challenge. Please ensure your are passing the HOST header properly.", 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
|
||||||
|
}
|
57
acme/http_challenge_test.go
Normal file
57
acme/http_challenge_test.go
Normal 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: string(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: string(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, want18 := "invalid port 123456", "123456: invalid port"; !strings.HasSuffix(err.Error(), want) && !strings.HasSuffix(err.Error(), want18) {
|
||||||
|
t.Errorf("Solve error: got %q, want suffix %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
100
acme/http_test.go
Normal file
100
acme/http_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
167
acme/jws.go
Normal file
167
acme/jws.go
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rsa"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gopkg.in/square/go-jose.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type jws struct {
|
||||||
|
getNonceURL string
|
||||||
|
privKey crypto.PrivateKey
|
||||||
|
kid string
|
||||||
|
nonces nonceManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posts a JWS signed message to the specified URL.
|
||||||
|
// It does NOT close the response body, so the caller must
|
||||||
|
// do that if no error was returned.
|
||||||
|
func (j *jws) post(url string, content []byte) (*http.Response, error) {
|
||||||
|
signedContent, err := j.signContent(url, content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to sign content -> %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
data := bytes.NewBuffer([]byte(signedContent.FullSerialize()))
|
||||||
|
resp, err := httpPost(url, "application/jose+json", data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to HTTP POST to %s -> %s", url, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, nonceErr := getNonceFromResponse(resp)
|
||||||
|
if nonceErr == nil {
|
||||||
|
j.nonces.Push(nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonKey := jose.JSONWebKey{
|
||||||
|
Key: j.privKey,
|
||||||
|
KeyID: j.kid,
|
||||||
|
}
|
||||||
|
|
||||||
|
signKey := jose.SigningKey{
|
||||||
|
Algorithm: alg,
|
||||||
|
Key: jsonKey,
|
||||||
|
}
|
||||||
|
options := jose.SignerOptions{
|
||||||
|
NonceSource: j,
|
||||||
|
ExtraHeaders: make(map[jose.HeaderKey]interface{}),
|
||||||
|
}
|
||||||
|
options.ExtraHeaders["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 -> %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
signed, err := signer.Sign(content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to sign content -> %s", err.Error())
|
||||||
|
}
|
||||||
|
return signed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 -> %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
signed, err := signer.Sign(jwkJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to External Account Binding sign content -> %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return signed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jws) Nonce() (string, error) {
|
||||||
|
if nonce, ok := j.nonces.Pop(); ok {
|
||||||
|
return nonce, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return getNonce(j.getNonceURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
type nonceManager struct {
|
||||||
|
nonces []string
|
||||||
|
sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *nonceManager) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *nonceManager) Push(nonce string) {
|
||||||
|
n.Lock()
|
||||||
|
defer n.Unlock()
|
||||||
|
n.nonces = append(n.nonces, nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNonce(url string) (string, error) {
|
||||||
|
resp, err := httpHead(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get nonce from HTTP HEAD -> %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return getNonceFromResponse(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNonceFromResponse(resp *http.Response) (string, error) {
|
||||||
|
nonce := resp.Header.Get("Replay-Nonce")
|
||||||
|
if nonce == "" {
|
||||||
|
return "", fmt.Errorf("server did not respond with a proper nonce header")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nonce, nil
|
||||||
|
}
|
103
acme/messages.go
Normal file
103
acme/messages.go
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegistrationResource represents all important informations about a registration
|
||||||
|
// of which the client needs to keep track itself.
|
||||||
|
type RegistrationResource struct {
|
||||||
|
Body accountMessage `json:"body,omitempty"`
|
||||||
|
URI string `json:"uri,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type directory struct {
|
||||||
|
NewNonceURL string `json:"newNonce"`
|
||||||
|
NewAccountURL string `json:"newAccount"`
|
||||||
|
NewOrderURL string `json:"newOrder"`
|
||||||
|
RevokeCertURL string `json:"revokeCert"`
|
||||||
|
KeyChangeURL string `json:"keyChange"`
|
||||||
|
Meta struct {
|
||||||
|
TermsOfService string `json:"termsOfService"`
|
||||||
|
Website string `json:"website"`
|
||||||
|
CaaIdentities []string `json:"caaIdentities"`
|
||||||
|
ExternalAccountRequired bool `json:"externalAccountRequired"`
|
||||||
|
} `json:"meta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountMessage struct {
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Contact []string `json:"contact,omitempty"`
|
||||||
|
TermsOfServiceAgreed bool `json:"termsOfServiceAgreed,omitempty"`
|
||||||
|
Orders string `json:"orders,omitempty"`
|
||||||
|
OnlyReturnExisting bool `json:"onlyReturnExisting,omitempty"`
|
||||||
|
ExternalAccountBinding json.RawMessage `json:"externalAccountBinding,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderResource struct {
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Domains []string `json:"domains,omitempty"`
|
||||||
|
orderMessage `json:"body,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderMessage struct {
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Expires string `json:"expires,omitempty"`
|
||||||
|
Identifiers []identifier `json:"identifiers"`
|
||||||
|
NotBefore string `json:"notBefore,omitempty"`
|
||||||
|
NotAfter string `json:"notAfter,omitempty"`
|
||||||
|
Authorizations []string `json:"authorizations,omitempty"`
|
||||||
|
Finalize string `json:"finalize,omitempty"`
|
||||||
|
Certificate string `json:"certificate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type authorization struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Expires time.Time `json:"expires"`
|
||||||
|
Identifier identifier `json:"identifier"`
|
||||||
|
Challenges []challenge `json:"challenges"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type identifier struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type challenge struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Validated time.Time `json:"validated"`
|
||||||
|
KeyAuthorization string `json:"keyAuthorization"`
|
||||||
|
Error RemoteError `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type csrMessage struct {
|
||||||
|
Csr string `json:"csr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type revokeCertMessage struct {
|
||||||
|
Certificate string `json:"certificate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deactivateAuthMessage struct {
|
||||||
|
Status string `jsom:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertificateResource 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 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:"-"`
|
||||||
|
IssuerCertificate []byte `json:"-"`
|
||||||
|
CSR []byte `json:"-"`
|
||||||
|
}
|
|
@ -1,28 +1,28 @@
|
||||||
package challenge
|
package acme
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// Provider enables implementing a custom challenge
|
// ChallengeProvider enables implementing a custom challenge
|
||||||
// provider. Present presents the solution to a challenge available to
|
// provider. Present presents the solution to a challenge available to
|
||||||
// be solved. CleanUp will be called by the challenge if Present ends
|
// be solved. CleanUp will be called by the challenge if Present ends
|
||||||
// in a non-error state.
|
// in a non-error state.
|
||||||
type Provider interface {
|
type ChallengeProvider interface {
|
||||||
Present(domain, token, keyAuth string) error
|
Present(domain, token, keyAuth string) error
|
||||||
CleanUp(domain, token, keyAuth string) error
|
CleanUp(domain, token, keyAuth string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProviderTimeout allows for implementing a
|
// ChallengeProviderTimeout allows for implementing a
|
||||||
// Provider where an unusually long timeout is required when
|
// ChallengeProvider where an unusually long timeout is required when
|
||||||
// waiting for an ACME challenge to be satisfied, such as when
|
// waiting for an ACME challenge to be satisfied, such as when
|
||||||
// checking for DNS record propagation. If an implementor of a
|
// checking for DNS record progagation. If an implementor of a
|
||||||
// Provider provides a Timeout method, then the return values
|
// ChallengeProvider provides a Timeout method, then the return values
|
||||||
// of the Timeout method will be used when appropriate by the acme
|
// of the Timeout method will be used when appropriate by the acme
|
||||||
// package. The interval value is the time between checks.
|
// package. The interval value is the time between checks.
|
||||||
//
|
//
|
||||||
// The default values used for timeout and interval are 60 seconds and
|
// The default values used for timeout and interval are 60 seconds and
|
||||||
// 2 seconds respectively. These are used when no Timeout method is
|
// 2 seconds respectively. These are used when no Timeout method is
|
||||||
// defined for the Provider.
|
// defined for the ChallengeProvider.
|
||||||
type ProviderTimeout interface {
|
type ChallengeProviderTimeout interface {
|
||||||
Provider
|
ChallengeProvider
|
||||||
Timeout() (timeout, interval time.Duration)
|
Timeout() (timeout, interval time.Duration)
|
||||||
}
|
}
|
29
acme/utils.go
Normal file
29
acme/utils.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,19 +1,20 @@
|
||||||
package wait
|
package acme
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestForTimeout(t *testing.T) {
|
func TestWaitForTimeout(t *testing.T) {
|
||||||
c := make(chan error)
|
c := make(chan error)
|
||||||
go func() {
|
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
|
return false, nil
|
||||||
})
|
})
|
||||||
|
c <- err
|
||||||
}()
|
}()
|
||||||
|
|
||||||
timeout := time.After(6 * time.Second)
|
timeout := time.After(4 * time.Second)
|
||||||
select {
|
select {
|
||||||
case <-timeout:
|
case <-timeout:
|
||||||
t.Fatal("timeout exceeded")
|
t.Fatal("timeout exceeded")
|
||||||
|
@ -21,6 +22,5 @@ func TestForTimeout(t *testing.T) {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("expected timeout error; got %v", err)
|
t.Errorf("expected timeout error; got %v", err)
|
||||||
}
|
}
|
||||||
t.Logf("%v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 ""
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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()
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,118 +0,0 @@
|
||||||
package resolver
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/go-acme/lego/v4/acme"
|
|
||||||
"github.com/go-acme/lego/v4/challenge"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestProber_Solve(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
desc string
|
|
||||||
solvers map[challenge.Type]solver
|
|
||||||
authz []acme.Authorization
|
|
||||||
expectedError string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
desc: "success",
|
|
||||||
solvers: map[challenge.Type]solver{
|
|
||||||
challenge.HTTP01: &preSolverMock{
|
|
||||||
preSolve: map[string]error{},
|
|
||||||
solve: map[string]error{},
|
|
||||||
cleanUp: map[string]error{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
authz: []acme.Authorization{
|
|
||||||
createStubAuthorizationHTTP01("acme.wtf", acme.StatusProcessing),
|
|
||||||
createStubAuthorizationHTTP01("lego.wtf", acme.StatusProcessing),
|
|
||||||
createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusProcessing),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
desc: "already valid",
|
|
||||||
solvers: map[challenge.Type]solver{
|
|
||||||
challenge.HTTP01: &preSolverMock{
|
|
||||||
preSolve: map[string]error{},
|
|
||||||
solve: map[string]error{},
|
|
||||||
cleanUp: map[string]error{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
authz: []acme.Authorization{
|
|
||||||
createStubAuthorizationHTTP01("acme.wtf", acme.StatusValid),
|
|
||||||
createStubAuthorizationHTTP01("lego.wtf", acme.StatusValid),
|
|
||||||
createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusValid),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
desc: "when preSolve fail, auth is flagged as error and skipped",
|
|
||||||
solvers: map[challenge.Type]solver{
|
|
||||||
challenge.HTTP01: &preSolverMock{
|
|
||||||
preSolve: map[string]error{
|
|
||||||
"acme.wtf": errors.New("preSolve error acme.wtf"),
|
|
||||||
},
|
|
||||||
solve: map[string]error{
|
|
||||||
"acme.wtf": errors.New("solve error acme.wtf"),
|
|
||||||
},
|
|
||||||
cleanUp: map[string]error{
|
|
||||||
"acme.wtf": errors.New("clean error acme.wtf"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
authz: []acme.Authorization{
|
|
||||||
createStubAuthorizationHTTP01("acme.wtf", acme.StatusProcessing),
|
|
||||||
createStubAuthorizationHTTP01("lego.wtf", acme.StatusProcessing),
|
|
||||||
createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusProcessing),
|
|
||||||
},
|
|
||||||
expectedError: `error: one or more domains had a problem:
|
|
||||||
[acme.wtf] preSolve error acme.wtf
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
desc: "errors at different stages",
|
|
||||||
solvers: map[challenge.Type]solver{
|
|
||||||
challenge.HTTP01: &preSolverMock{
|
|
||||||
preSolve: map[string]error{
|
|
||||||
"acme.wtf": errors.New("preSolve error acme.wtf"),
|
|
||||||
},
|
|
||||||
solve: map[string]error{
|
|
||||||
"acme.wtf": errors.New("solve error acme.wtf"),
|
|
||||||
"lego.wtf": errors.New("solve error lego.wtf"),
|
|
||||||
},
|
|
||||||
cleanUp: map[string]error{
|
|
||||||
"mydomain.wtf": errors.New("clean error mydomain.wtf"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
authz: []acme.Authorization{
|
|
||||||
createStubAuthorizationHTTP01("acme.wtf", acme.StatusProcessing),
|
|
||||||
createStubAuthorizationHTTP01("lego.wtf", acme.StatusProcessing),
|
|
||||||
createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusProcessing),
|
|
||||||
},
|
|
||||||
expectedError: `error: one or more domains had a problem:
|
|
||||||
[acme.wtf] preSolve error acme.wtf
|
|
||||||
[lego.wtf] solve error lego.wtf
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range testCases {
|
|
||||||
test := test
|
|
||||||
t.Run(test.desc, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
prober := &Prober{
|
|
||||||
solverManager: &SolverManager{solvers: test.solvers},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := prober.Solve(test.authz)
|
|
||||||
if test.expectedError != "" {
|
|
||||||
require.EqualError(t, err, test.expectedError)
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,171 +0,0 @@
|
||||||
package resolver
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
|
||||||
"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/challenge/dns01"
|
|
||||||
"github.com/go-acme/lego/v4/challenge/http01"
|
|
||||||
"github.com/go-acme/lego/v4/challenge/nns01"
|
|
||||||
"github.com/go-acme/lego/v4/challenge/tlsalpn01"
|
|
||||||
"github.com/go-acme/lego/v4/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
type byType []acme.Challenge
|
|
||||||
|
|
||||||
func (a byType) Len() int { return len(a) }
|
|
||||||
func (a byType) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
|
||||||
func (a byType) Less(i, j int) bool { return a[i].Type > a[j].Type }
|
|
||||||
|
|
||||||
type SolverManager struct {
|
|
||||||
core *api.Core
|
|
||||||
solvers map[challenge.Type]solver
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSolversManager(core *api.Core) *SolverManager {
|
|
||||||
return &SolverManager{
|
|
||||||
solvers: map[challenge.Type]solver{},
|
|
||||||
core: core,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetHTTP01Provider specifies a custom provider p that can solve the given HTTP-01 challenge.
|
|
||||||
func (c *SolverManager) SetHTTP01Provider(p challenge.Provider) error {
|
|
||||||
c.solvers[challenge.HTTP01] = http01.NewChallenge(c.core, validate, p)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetTLSALPN01Provider specifies a custom provider p that can solve the given TLS-ALPN-01 challenge.
|
|
||||||
func (c *SolverManager) SetTLSALPN01Provider(p challenge.Provider) error {
|
|
||||||
c.solvers[challenge.TLSALPN01] = tlsalpn01.NewChallenge(c.core, validate, p)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetDNS01Provider specifies a custom provider p that can solve the given DNS-01 challenge.
|
|
||||||
func (c *SolverManager) SetDNS01Provider(p challenge.Provider, opts ...dns01.ChallengeOption) error {
|
|
||||||
c.solvers[challenge.DNS01] = dns01.NewChallenge(c.core, validate, p, opts...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetNNS01Provider specifies a custom provider p that can solve the given NNS-01 challenge.
|
|
||||||
func (c *SolverManager) SetNNS01Provider(p challenge.Provider) error {
|
|
||||||
c.solvers[challenge.NNS01] = nns01.NewChallenge(c.core, validate, p)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove removes a challenge type from the available solvers.
|
|
||||||
func (c *SolverManager) Remove(chlgType challenge.Type) {
|
|
||||||
delete(c.solvers, chlgType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checks all challenges from the server in order and returns the first matching solver.
|
|
||||||
func (c *SolverManager) chooseSolver(authz acme.Authorization) solver {
|
|
||||||
// Allow to have a deterministic challenge order
|
|
||||||
sort.Sort(byType(authz.Challenges))
|
|
||||||
|
|
||||||
domain := challenge.GetTargetedDomain(authz)
|
|
||||||
for _, chlg := range authz.Challenges {
|
|
||||||
if solvr, ok := c.solvers[challenge.Type(chlg.Type)]; ok {
|
|
||||||
log.Infof("[%s] acme: use %s solver", domain, chlg.Type)
|
|
||||||
return solvr
|
|
||||||
}
|
|
||||||
log.Infof("[%s] acme: Could not find solver for: %s", domain, chlg.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validate(core *api.Core, domain string, chlg acme.Challenge) error {
|
|
||||||
chlng, err := core.Challenges.New(chlg.URL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to initiate challenge: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
valid, err := checkChallengeStatus(chlng)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if valid {
|
|
||||||
log.Infof("[%s] The server validated our request", domain)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ra, err := strconv.Atoi(chlng.RetryAfter)
|
|
||||||
if err != nil {
|
|
||||||
// The ACME server MUST return a Retry-After.
|
|
||||||
// If it doesn't, we'll just poll hard.
|
|
||||||
// Boulder does not implement the ability to retry challenges or the Retry-After header.
|
|
||||||
// https://github.com/letsencrypt/boulder/blob/master/docs/acme-divergences.md#section-82
|
|
||||||
ra = 5
|
|
||||||
}
|
|
||||||
initialInterval := time.Duration(ra) * time.Second
|
|
||||||
|
|
||||||
bo := backoff.NewExponentialBackOff()
|
|
||||||
bo.InitialInterval = initialInterval
|
|
||||||
bo.MaxInterval = 10 * initialInterval
|
|
||||||
bo.MaxElapsedTime = 100 * initialInterval
|
|
||||||
|
|
||||||
// After the path is sent, the ACME server will access our server.
|
|
||||||
// Repeatedly check the server for an updated status on our request.
|
|
||||||
operation := func() error {
|
|
||||||
authz, err := core.Authorizations.Get(chlng.AuthorizationURL)
|
|
||||||
if err != nil {
|
|
||||||
return backoff.Permanent(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
valid, err := checkAuthorizationStatus(authz)
|
|
||||||
if err != nil {
|
|
||||||
return backoff.Permanent(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if valid {
|
|
||||||
log.Infof("[%s] The server validated our request", domain)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors.New("the server didn't respond to our request")
|
|
||||||
}
|
|
||||||
|
|
||||||
return backoff.Retry(operation, bo)
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkChallengeStatus(chlng acme.ExtendedChallenge) (bool, error) {
|
|
||||||
switch chlng.Status {
|
|
||||||
case acme.StatusValid:
|
|
||||||
return true, nil
|
|
||||||
case acme.StatusPending, acme.StatusProcessing:
|
|
||||||
return false, nil
|
|
||||||
case acme.StatusInvalid:
|
|
||||||
return false, chlng.Error
|
|
||||||
default:
|
|
||||||
return false, errors.New("the server returned an unexpected state")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkAuthorizationStatus(authz acme.Authorization) (bool, error) {
|
|
||||||
switch authz.Status {
|
|
||||||
case acme.StatusValid:
|
|
||||||
return true, nil
|
|
||||||
case acme.StatusPending, acme.StatusProcessing:
|
|
||||||
return false, nil
|
|
||||||
case acme.StatusDeactivated, acme.StatusExpired, acme.StatusRevoked:
|
|
||||||
return false, fmt.Errorf("the authorization state %s", authz.Status)
|
|
||||||
case acme.StatusInvalid:
|
|
||||||
for _, chlg := range authz.Challenges {
|
|
||||||
if chlg.Status == acme.StatusInvalid && chlg.Error != nil {
|
|
||||||
return false, chlg.Error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, fmt.Errorf("the authorization state %s", authz.Status)
|
|
||||||
default:
|
|
||||||
return false, errors.New("the server returned an unexpected state")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,178 +0,0 @@
|
||||||
package resolver
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/go-acme/lego/v4/acme"
|
|
||||||
"github.com/go-acme/lego/v4/acme/api"
|
|
||||||
"github.com/go-acme/lego/v4/platform/tester"
|
|
||||||
"github.com/go-jose/go-jose/v3"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestByType(t *testing.T) {
|
|
||||||
challenges := []acme.Challenge{
|
|
||||||
{Type: "dns-01"}, {Type: "tlsalpn-01"}, {Type: "http-01"},
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Sort(byType(challenges))
|
|
||||||
|
|
||||||
expected := []acme.Challenge{
|
|
||||||
{Type: "tlsalpn-01"}, {Type: "http-01"}, {Type: "dns-01"},
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, expected, challenges)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidate(t *testing.T) {
|
|
||||||
mux, apiURL := tester.SetupFakeAPI(t)
|
|
||||||
|
|
||||||
var statuses []string
|
|
||||||
|
|
||||||
privateKey, _ := rsa.GenerateKey(rand.Reader, 512)
|
|
||||||
|
|
||||||
mux.HandleFunc("/chlg", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validateNoBody(privateKey, r); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Link", "<"+apiURL+`/my-authz>; rel="up"`)
|
|
||||||
|
|
||||||
st := statuses[0]
|
|
||||||
statuses = statuses[1:]
|
|
||||||
|
|
||||||
chlg := &acme.Challenge{Type: "http-01", Status: st, URL: "http://example.com/", Token: "token"}
|
|
||||||
if st == acme.StatusInvalid {
|
|
||||||
chlg.Error = &acme.ProblemDetails{}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := tester.WriteJSONResponse(w, chlg)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/my-authz", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
st := statuses[0]
|
|
||||||
statuses = statuses[1:]
|
|
||||||
|
|
||||||
authorization := acme.Authorization{
|
|
||||||
Status: st,
|
|
||||||
Challenges: []acme.Challenge{},
|
|
||||||
}
|
|
||||||
|
|
||||||
if st == acme.StatusInvalid {
|
|
||||||
chlg := acme.Challenge{
|
|
||||||
Status: acme.StatusInvalid,
|
|
||||||
Error: &acme.ProblemDetails{},
|
|
||||||
}
|
|
||||||
authorization.Challenges = append(authorization.Challenges, chlg)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := tester.WriteJSONResponse(w, authorization)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
statuses []string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "POST-unexpected",
|
|
||||||
statuses: []string{"weird"},
|
|
||||||
want: "unexpected",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "POST-valid",
|
|
||||||
statuses: []string{acme.StatusValid},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "POST-invalid",
|
|
||||||
statuses: []string{acme.StatusInvalid},
|
|
||||||
want: "error",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "POST-pending-unexpected",
|
|
||||||
statuses: []string{acme.StatusPending, "weird"},
|
|
||||||
want: "unexpected",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "POST-pending-valid",
|
|
||||||
statuses: []string{acme.StatusPending, acme.StatusValid},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "POST-pending-invalid",
|
|
||||||
statuses: []string{acme.StatusPending, acme.StatusInvalid},
|
|
||||||
want: "error",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range testCases {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
statuses = test.statuses
|
|
||||||
|
|
||||||
err := validate(core, "example.com", acme.Challenge{Type: "http-01", Token: "token", URL: apiURL + "/chlg"})
|
|
||||||
if test.want == "" {
|
|
||||||
require.NoError(t, err)
|
|
||||||
} else {
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), test.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateNoBody reads the http.Request POST body, parses the JWS and validates it to read the body.
|
|
||||||
// If there is an error doing this,
|
|
||||||
// or if the JWS body is not the empty JSON payload "{}" or a POST-as-GET payload "" an error is returned.
|
|
||||||
// We use this to verify challenge POSTs to the ts below do not send a JWS body.
|
|
||||||
func validateNoBody(privateKey *rsa.PrivateKey, r *http.Request) error {
|
|
||||||
reqBody, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
jws, err := jose.ParseSigned(string(reqBody))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := jws.Verify(&jose.JSONWebKey{
|
|
||||||
Key: privateKey.Public(),
|
|
||||||
Algorithm: "RSA",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if bodyStr := string(body); bodyStr != "{}" && bodyStr != "" {
|
|
||||||
return fmt.Errorf(`expected JWS POST body "{}" or "", got %q`, bodyStr)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -1,129 +0,0 @@
|
||||||
package tlsalpn01
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rsa"
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509/pkix"
|
|
||||||
"encoding/asn1"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// idPeAcmeIdentifierV1 is the SMI Security for PKIX Certification Extension OID referencing the ACME extension.
|
|
||||||
// Reference: https://www.rfc-editor.org/rfc/rfc8737.html#section-6.1
|
|
||||||
var idPeAcmeIdentifierV1 = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 31}
|
|
||||||
|
|
||||||
type ValidateFunc func(core *api.Core, domain string, chlng acme.Challenge) error
|
|
||||||
|
|
||||||
type 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Solve manages the provider to validate and solve the challenge.
|
|
||||||
func (c *Challenge) Solve(authz acme.Authorization) error {
|
|
||||||
domain := authz.Identifier.Value
|
|
||||||
log.Infof("[%s] acme: Trying to solve TLS-ALPN-01", challenge.GetTargetedDomain(authz))
|
|
||||||
|
|
||||||
chlng, err := challenge.FindChallenge(challenge.TLSALPN01, 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(domain, chlng.Token, keyAuth)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("[%s] acme: error presenting token: %w", challenge.GetTargetedDomain(authz), err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
err := c.provider.CleanUp(domain, chlng.Token, keyAuth)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("[%s] acme: cleaning up failed: %v", challenge.GetTargetedDomain(authz), err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
chlng.KeyAuthorization = keyAuth
|
|
||||||
return c.validate(c.core, domain, chlng)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChallengeBlocks returns PEM blocks (certPEMBlock, keyPEMBlock) with the acmeValidation-v1 extension
|
|
||||||
// and domain name for the `tls-alpn-01` challenge.
|
|
||||||
func ChallengeBlocks(domain, keyAuth string) ([]byte, []byte, error) {
|
|
||||||
// Compute the SHA-256 digest of the key authorization.
|
|
||||||
zBytes := sha256.Sum256([]byte(keyAuth))
|
|
||||||
|
|
||||||
value, err := asn1.Marshal(zBytes[:sha256.Size])
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the keyAuth digest as the acmeValidation-v1 extension
|
|
||||||
// (marked as critical such that it won't be used by non-ACME software).
|
|
||||||
// Reference: https://www.rfc-editor.org/rfc/rfc8737.html#section-3
|
|
||||||
extensions := []pkix.Extension{
|
|
||||||
{
|
|
||||||
Id: idPeAcmeIdentifierV1,
|
|
||||||
Critical: true,
|
|
||||||
Value: value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a new RSA key for the certificates.
|
|
||||||
tempPrivateKey, err := certcrypto.GeneratePrivateKey(certcrypto.RSA2048)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rsaPrivateKey := tempPrivateKey.(*rsa.PrivateKey)
|
|
||||||
|
|
||||||
// Generate the PEM certificate using the provided private key, domain, and extra extensions.
|
|
||||||
tempCertPEM, err := certcrypto.GeneratePemCert(rsaPrivateKey, domain, extensions)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode the private key into a PEM format. We'll need to use it to generate the x509 keypair.
|
|
||||||
rsaPrivatePEM := certcrypto.PEMEncode(rsaPrivateKey)
|
|
||||||
|
|
||||||
return tempCertPEM, rsaPrivatePEM, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChallengeCert returns a certificate with the acmeValidation-v1 extension
|
|
||||||
// and domain name for the `tls-alpn-01` challenge.
|
|
||||||
func ChallengeCert(domain, keyAuth string) (*tls.Certificate, error) {
|
|
||||||
tempCertPEM, rsaPrivatePEM, err := ChallengeBlocks(domain, keyAuth)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cert, err := tls.X509KeyPair(tempCertPEM, rsaPrivatePEM)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &cert, nil
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue