forked from TrueCloudLab/lego
Refactor the core of the lib (#700)
- Packages - Isolate code used by the CLI into the package `cmd` - (experimental) Add e2e tests for HTTP01, TLS-ALPN-01 and DNS-01, use [Pebble](https://github.com/letsencrypt/pebble) and [challtestsrv](https://github.com/letsencrypt/boulder/tree/master/test/challtestsrv) - Support non-ascii domain name (punnycode) - Check all challenges in a predictable order - No more global exported variables - Archive revoked certificates - Fixes revocation for subdomains and non-ascii domains - Disable pending authorizations - use pointer for RemoteError/ProblemDetails - Poll authz URL instead of challenge URL - The ability for a DNS provider to solve the challenge sequentially - Check all nameservers in a predictable order - Option to disable the complete propagation Requirement - CLI, support for renew with CSR - CLI, add SAN on renew - Add command to list certificates. - Logs every iteration of waiting for the propagation - update DNSimple client - update github.com/miekg/dns
This commit is contained in:
parent
4e842a5eb6
commit
42941ccea6
308 changed files with 16463 additions and 10300 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,5 +1,3 @@
|
||||||
lego.exe
|
|
||||||
lego
|
|
||||||
.lego
|
.lego
|
||||||
.gitcookies
|
.gitcookies
|
||||||
.idea
|
.idea
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
[run]
|
[run]
|
||||||
deadline = "2m"
|
deadline = "5m"
|
||||||
|
skip-files = []
|
||||||
|
|
||||||
[linters-settings]
|
[linters-settings]
|
||||||
|
|
||||||
|
@ -7,13 +8,13 @@
|
||||||
check-shadowing = true
|
check-shadowing = true
|
||||||
|
|
||||||
[linters-settings.gocyclo]
|
[linters-settings.gocyclo]
|
||||||
min-complexity = 16.0
|
min-complexity = 12.0
|
||||||
|
|
||||||
[linters-settings.maligned]
|
[linters-settings.maligned]
|
||||||
suggest-new = true
|
suggest-new = true
|
||||||
|
|
||||||
[linters-settings.goconst]
|
[linters-settings.goconst]
|
||||||
min-len = 2.0
|
min-len = 3.0
|
||||||
min-occurrences = 3.0
|
min-occurrences = 3.0
|
||||||
|
|
||||||
[linters-settings.misspell]
|
[linters-settings.misspell]
|
||||||
|
@ -27,15 +28,27 @@
|
||||||
"gas",
|
"gas",
|
||||||
"dupl",
|
"dupl",
|
||||||
"prealloc",
|
"prealloc",
|
||||||
|
"scopelint",
|
||||||
]
|
]
|
||||||
|
|
||||||
[issues]
|
[issues]
|
||||||
|
exclude-use-default = false
|
||||||
max-per-linter = 0
|
max-per-linter = 0
|
||||||
max-same = 0
|
max-same = 0
|
||||||
exclude = [
|
exclude = [
|
||||||
"func (.+)disableAuthz(.) is unused", # acme/client.go#disableAuthz
|
"Error return value of (.+) is not checked",
|
||||||
"type (.+)deactivateAuthMessage(.) is unused", # acme/messages.go#deactivateAuthMessage
|
"exported (type|method|function) (.+) should have comment or be unexported",
|
||||||
"(.)limitReader(.) - (.)numBytes(.) always receives (.)1048576(.)", # acme/crypto.go#limitReader
|
"possible misuse of unsafe.Pointer",
|
||||||
"cyclomatic complexity (\\d+) of func (.)NewDNSChallengeProviderByName(.) is high", # providers/dns/dns_providers.go#NewDNSChallengeProviderByName
|
"cyclomatic complexity (.+) of func `NewDNSChallengeProviderByName` is high (.+)", # providers/dns/dns_providers.go
|
||||||
"cyclomatic complexity (\\d+) of func (.)setup(.) is high", # cli_handler.go#setup
|
|
||||||
|
"`(tlsFeatureExtensionOID|ocspMustStapleFeature)` is a global variable", # certcrypto/crypto.go
|
||||||
|
"`(defaultNameservers|recursiveNameservers|dnsTimeout|fqdnToZone|muFqdnToZone)` is a global variable", # challenge/dns01/nameserver.go
|
||||||
|
"`idPeAcmeIdentifierV1` is a global variable", # challenge/tlsalpn01/tls_alpn_challenge.go
|
||||||
|
"`Logger` is a global variable", # log/logger.go
|
||||||
|
"`version` is a global variable", # cli.go
|
||||||
|
"`load` is a global variable", # e2e/challenges_test.go
|
||||||
|
"`envTest` is a global variable", # providers/dns/**/*_test.go
|
||||||
|
"`(tldsMock|testCases)` is a global variable", # providers/dns/namecheap/namecheap_test.go
|
||||||
|
"`(errorClientErr|errorStorageErr|egTestAccount)` is a global variable", # providers/dns/acmedns/acmedns_test.go
|
||||||
|
"`memcachedHosts` is a global variable", # providers/http/memcached/memcached_test.go
|
||||||
]
|
]
|
||||||
|
|
|
@ -2,6 +2,11 @@ project_name: lego
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- binary: lego
|
- binary: lego
|
||||||
|
|
||||||
|
main: ./cmd/lego/main.go
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X main.version={{.Version}}
|
||||||
|
|
||||||
goos:
|
goos:
|
||||||
- windows
|
- windows
|
||||||
- darwin
|
- darwin
|
||||||
|
|
19
.travis.yml
19
.travis.yml
|
@ -1,12 +1,21 @@
|
||||||
language: go
|
language: go
|
||||||
|
|
||||||
go:
|
go:
|
||||||
- 1.9.x
|
- 1.10.x
|
||||||
- 1.x
|
- 1.x
|
||||||
|
|
||||||
services:
|
services:
|
||||||
- memcached
|
- memcached
|
||||||
|
|
||||||
|
addons:
|
||||||
|
hosts:
|
||||||
|
# for e2e tests
|
||||||
|
- acme.wtf
|
||||||
|
- lego.wtf
|
||||||
|
- acme.lego.wtf
|
||||||
|
- légô.wtf
|
||||||
|
- xn--lg-bja9b.wtf
|
||||||
|
|
||||||
env:
|
env:
|
||||||
- MEMCACHED_HOSTS=localhost:11211
|
- MEMCACHED_HOSTS=localhost:11211
|
||||||
|
|
||||||
|
@ -17,8 +26,14 @@ before_install:
|
||||||
- curl -sI https://github.com/golang/dep/releases/latest | grep -Fi Location | tr -d '\r' | sed "s/tag/download/g" | awk -F " " '{ print $2 "/dep-linux-amd64"}' | wget --output-document=$GOPATH/bin/dep -i -
|
- curl -sI https://github.com/golang/dep/releases/latest | grep -Fi Location | tr -d '\r' | sed "s/tag/download/g" | awk -F " " '{ print $2 "/dep-linux-amd64"}' | wget --output-document=$GOPATH/bin/dep -i -
|
||||||
- chmod +x $GOPATH/bin/dep
|
- chmod +x $GOPATH/bin/dep
|
||||||
|
|
||||||
|
# Install Pebble
|
||||||
|
- go get -u github.com/letsencrypt/pebble/...
|
||||||
|
|
||||||
|
# Install challtestsrv
|
||||||
|
- go get -u github.com/letsencrypt/boulder/test/challtestsrv/...
|
||||||
|
|
||||||
# Install linters and misspell
|
# Install linters and misspell
|
||||||
- curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | bash -s -- -b $GOPATH/bin v1.10.2
|
- curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | bash -s -- -b $GOPATH/bin v1.12.2
|
||||||
- golangci-lint --version
|
- golangci-lint --version
|
||||||
|
|
||||||
install:
|
install:
|
||||||
|
|
|
@ -67,6 +67,7 @@ owners to license your work under the terms of the [MIT License](LICENSE).
|
||||||
| Namecheap | `namecheap` | [documentation](https://www.namecheap.com/support/api/methods.aspx) | - |
|
| Namecheap | `namecheap` | [documentation](https://www.namecheap.com/support/api/methods.aspx) | - |
|
||||||
| Name.com | `namedotcom` | [documentation](https://www.name.com/api-docs/DNS) | [Go client](https://github.com/namedotcom/go) |
|
| Name.com | `namedotcom` | [documentation](https://www.name.com/api-docs/DNS) | [Go client](https://github.com/namedotcom/go) |
|
||||||
| manual | `manual` | - | - |
|
| manual | `manual` | - | - |
|
||||||
|
| MyDNS.jp | `mydnsjp` | [documentation](https://www.mydns.jp/?MENU=030) | - |
|
||||||
| Netcup | `netcup` | [documentation](https://www.netcup-wiki.de/wiki/DNS_API) | - |
|
| Netcup | `netcup` | [documentation](https://www.netcup-wiki.de/wiki/DNS_API) | - |
|
||||||
| NIFCloud | `nifcloud` | [documentation](https://mbaas.nifcloud.com/doc/current/rest/common/format.html) | - |
|
| NIFCloud | `nifcloud` | [documentation](https://mbaas.nifcloud.com/doc/current/rest/common/format.html) | - |
|
||||||
| NS1 | `ns1` | [documentation](https://ns1.com/api) | [Go client](https://github.com/ns1/ns1-go) |
|
| NS1 | `ns1` | [documentation](https://ns1.com/api) | [Go client](https://github.com/ns1/ns1-go) |
|
||||||
|
@ -77,8 +78,9 @@ owners to license your work under the terms of the [MIT License](LICENSE).
|
||||||
| RFC2136 | `rfc2136` | [documentation](https://tools.ietf.org/html/rfc2136) | - |
|
| RFC2136 | `rfc2136` | [documentation](https://tools.ietf.org/html/rfc2136) | - |
|
||||||
| Route 53 | `route53` | [documentation](https://docs.aws.amazon.com/Route53/latest/APIReference/API_Operations_Amazon_Route_53.html) | [Go client](https://github.com/aws/aws-sdk-go/aws) |
|
| Route 53 | `route53` | [documentation](https://docs.aws.amazon.com/Route53/latest/APIReference/API_Operations_Amazon_Route_53.html) | [Go client](https://github.com/aws/aws-sdk-go/aws) |
|
||||||
| Sakura Cloud | `sakuracloud` | [documentation](https://developer.sakura.ad.jp/cloud/api/1.1/) | [Go client](https://github.com/sacloud/libsacloud) |
|
| Sakura Cloud | `sakuracloud` | [documentation](https://developer.sakura.ad.jp/cloud/api/1.1/) | [Go client](https://github.com/sacloud/libsacloud) |
|
||||||
| Selectel | `selectel` | [documentation](https://kb.selectel.com/23136054.html) | - |
|
| Selectel | `selectel` | [documentation](https://kb.selectel.com/23136054.html) | - |
|
||||||
| Stackpath | `stackpath` | [documentation](https://developer.stackpath.com/en/api/dns/#tag/Zone) | - |
|
| Stackpath | `stackpath` | [documentation](https://developer.stackpath.com/en/api/dns/#tag/Zone) | - |
|
||||||
|
| TransIP | `transip` | [documentation](https://api.transip.nl/docs/transip.nl/package-Transip.html) | [Go client](https://github.com/transip/gotransip) |
|
||||||
| VegaDNS | `vegadns` | [documentation](https://github.com/shupp/VegaDNS-API) | [Go client](https://github.com/OpenDNS/vegadns2client) |
|
| VegaDNS | `vegadns` | [documentation](https://github.com/shupp/VegaDNS-API) | [Go client](https://github.com/OpenDNS/vegadns2client) |
|
||||||
| Vultr | `vultr` | [documentation](https://www.vultr.com/api/#dns) | [Go client](https://github.com/JamesClonk/vultr) |
|
| Vultr | `vultr` | [documentation](https://www.vultr.com/api/#dns) | [Go client](https://github.com/JamesClonk/vultr) |
|
||||||
| Vscale | `vscale` | [documentation](https://developers.vscale.io/documentation/api/v1/#api-Domains_Records) | - |
|
| Vscale | `vscale` | [documentation](https://developers.vscale.io/documentation/api/v1/#api-Domains_Records) | - |
|
|
@ -10,5 +10,5 @@ RUN make build
|
||||||
|
|
||||||
FROM alpine:3.8
|
FROM alpine:3.8
|
||||||
RUN apk update && apk add --no-cache --virtual ca-certificates
|
RUN apk update && apk add --no-cache --virtual ca-certificates
|
||||||
COPY --from=builder /go/src/github.com/xenolf/lego/lego /usr/bin/lego
|
COPY --from=builder /go/src/github.com/xenolf/lego/dist/lego /usr/bin/lego
|
||||||
ENTRYPOINT [ "/usr/bin/lego" ]
|
ENTRYPOINT [ "/usr/bin/lego" ]
|
||||||
|
|
29
Gopkg.lock
generated
29
Gopkg.lock
generated
|
@ -176,20 +176,20 @@
|
||||||
revision = "5448fe645cb1964ba70ac8f9f2ffe975e61a536c"
|
revision = "5448fe645cb1964ba70ac8f9f2ffe975e61a536c"
|
||||||
|
|
||||||
[[projects]]
|
[[projects]]
|
||||||
branch = "master"
|
digest = "1:e856fc44ab196970612bdc8c15e65ccf92ed8d4ccb3a2e65b88dc240a2fe5d0b"
|
||||||
digest = "1:6b873be0e0ec65484ee086d02143f31332e363b968fdc6d6663160fa98fda505"
|
|
||||||
name = "github.com/dnsimple/dnsimple-go"
|
name = "github.com/dnsimple/dnsimple-go"
|
||||||
packages = ["dnsimple"]
|
packages = ["dnsimple"]
|
||||||
pruneopts = "NUT"
|
pruneopts = "NUT"
|
||||||
revision = "35bcc6b47c20ec9bf3a53adcb7fa9665a75f0e7b"
|
revision = "f5ead9c20763fd925dea1362f2af5d671ed2a459"
|
||||||
|
version = "v0.21.0"
|
||||||
|
|
||||||
[[projects]]
|
[[projects]]
|
||||||
digest = "1:e096f1857eedd49e2bd0885d05105d1d4af1bfcf8b1d07fa5710718e6641fd48"
|
digest = "1:e68d50b8dc605565eb62df1c2b2c67fa729e5b55aa1a6c81456eecbe0326ecdb"
|
||||||
name = "github.com/exoscale/egoscale"
|
name = "github.com/exoscale/egoscale"
|
||||||
packages = ["."]
|
packages = ["."]
|
||||||
pruneopts = "NUT"
|
pruneopts = "NUT"
|
||||||
revision = "0863d555d5198557e0bf2b61b6c59a873ab0173a"
|
revision = "67368ae928a70cb5cb44ecf6f418ee33a1ade044"
|
||||||
version = "v0.11.1"
|
version = "v0.11.6"
|
||||||
|
|
||||||
[[projects]]
|
[[projects]]
|
||||||
digest = "1:aa3ed0a71c4e66e4ae6486bf97a3f4cab28edc78df2e50c5ad01dc7d91604b88"
|
digest = "1:aa3ed0a71c4e66e4ae6486bf97a3f4cab28edc78df2e50c5ad01dc7d91604b88"
|
||||||
|
@ -298,12 +298,12 @@
|
||||||
version = "v0.5.1"
|
version = "v0.5.1"
|
||||||
|
|
||||||
[[projects]]
|
[[projects]]
|
||||||
digest = "1:24b5f8d41224b90e3f4d22768926ed782a8ca481d945c0e064c8f165bf768280"
|
digest = "1:6676c63cef61a47c84eae578bcd8fe8352908ccfe3ea663c16797617a29e3c44"
|
||||||
name = "github.com/miekg/dns"
|
name = "github.com/miekg/dns"
|
||||||
packages = ["."]
|
packages = ["."]
|
||||||
pruneopts = "NUT"
|
pruneopts = "NUT"
|
||||||
revision = "5a2b9fab83ff0f8bfc99684bd5f43a37abe560f1"
|
revision = "a220737569d8137d4c610f80bd33f1dc762522e5"
|
||||||
version = "v1.0.8"
|
version = "v1.1.0"
|
||||||
|
|
||||||
[[projects]]
|
[[projects]]
|
||||||
digest = "1:a4df73029d2c42fabcb6b41e327d2f87e685284ec03edf76921c267d9cfc9c23"
|
digest = "1:a4df73029d2c42fabcb6b41e327d2f87e685284ec03edf76921c267d9cfc9c23"
|
||||||
|
@ -379,7 +379,7 @@
|
||||||
|
|
||||||
[[projects]]
|
[[projects]]
|
||||||
branch = "master"
|
branch = "master"
|
||||||
digest = "1:0f9362b2768972675cf28574249bfb5dd65556aac6ad1c36830b4bc8c2134926"
|
digest = "1:180e8ec2d3734b269a8a30b51dbca47fede2ce274fa76da2f00e664481cfb39e"
|
||||||
name = "github.com/sacloud/libsacloud"
|
name = "github.com/sacloud/libsacloud"
|
||||||
packages = [
|
packages = [
|
||||||
".",
|
".",
|
||||||
|
@ -388,7 +388,7 @@
|
||||||
"sacloud/ostype",
|
"sacloud/ostype",
|
||||||
]
|
]
|
||||||
pruneopts = "NUT"
|
pruneopts = "NUT"
|
||||||
revision = "7afff3fbc0a3bdff2e008fe2c429d44d9f66f209"
|
revision = "108b1efe4b4d106fee6760bdf1847c4f92e1a92e"
|
||||||
|
|
||||||
[[projects]]
|
[[projects]]
|
||||||
digest = "1:6bc0652ea6e39e22ccd522458b8bdd8665bf23bdc5a20eec90056e4dc7e273ca"
|
digest = "1:6bc0652ea6e39e22ccd522458b8bdd8665bf23bdc5a20eec90056e4dc7e273ca"
|
||||||
|
@ -613,7 +613,7 @@
|
||||||
revision = "028658c6d9be774b6d103a923d8c4b2715135c3f"
|
revision = "028658c6d9be774b6d103a923d8c4b2715135c3f"
|
||||||
|
|
||||||
[[projects]]
|
[[projects]]
|
||||||
digest = "1:3b7124c543146736e07107be13ea6288923c4a743e07c7a31d6b7209a00a9dab"
|
digest = "1:a50fabe7a46692dc7c656310add3d517abe7914df02afd151ef84da884605dc8"
|
||||||
name = "gopkg.in/square/go-jose.v2"
|
name = "gopkg.in/square/go-jose.v2"
|
||||||
packages = [
|
packages = [
|
||||||
".",
|
".",
|
||||||
|
@ -621,8 +621,8 @@
|
||||||
"json",
|
"json",
|
||||||
]
|
]
|
||||||
pruneopts = "NUT"
|
pruneopts = "NUT"
|
||||||
revision = "8254d6c783765f38c8675fae4427a1fe73fbd09d"
|
revision = "ef984e69dd356202fd4e4910d4d9c24468bdf0b8"
|
||||||
version = "v2.1.8"
|
version = "v2.1.9"
|
||||||
|
|
||||||
[solve-meta]
|
[solve-meta]
|
||||||
analyzer-name = "dep"
|
analyzer-name = "dep"
|
||||||
|
@ -676,6 +676,7 @@
|
||||||
"github.com/urfave/cli",
|
"github.com/urfave/cli",
|
||||||
"golang.org/x/crypto/ocsp",
|
"golang.org/x/crypto/ocsp",
|
||||||
"golang.org/x/net/context",
|
"golang.org/x/net/context",
|
||||||
|
"golang.org/x/net/idna",
|
||||||
"golang.org/x/net/publicsuffix",
|
"golang.org/x/net/publicsuffix",
|
||||||
"golang.org/x/oauth2",
|
"golang.org/x/oauth2",
|
||||||
"golang.org/x/oauth2/clientcredentials",
|
"golang.org/x/oauth2/clientcredentials",
|
||||||
|
|
|
@ -34,7 +34,7 @@
|
||||||
name = "github.com/decker502/dnspod-go"
|
name = "github.com/decker502/dnspod-go"
|
||||||
|
|
||||||
[[constraint]]
|
[[constraint]]
|
||||||
branch = "master"
|
version = "0.21.0"
|
||||||
name = "github.com/dnsimple/dnsimple-go"
|
name = "github.com/dnsimple/dnsimple-go"
|
||||||
|
|
||||||
[[constraint]]
|
[[constraint]]
|
||||||
|
@ -92,3 +92,7 @@
|
||||||
[[constraint]]
|
[[constraint]]
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
name = "github.com/exoscale/egoscale"
|
name = "github.com/exoscale/egoscale"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
version = "v1.1.0"
|
||||||
|
name = "github.com/miekg/dns"
|
||||||
|
|
18
Makefile
18
Makefile
|
@ -1,6 +1,10 @@
|
||||||
.PHONY: clean checks test build image dependencies
|
.PHONY: clean checks test build image dependencies
|
||||||
|
|
||||||
|
SRCS = $(shell git ls-files '*.go' | grep -v '^vendor/')
|
||||||
|
|
||||||
LEGO_IMAGE := xenolf/lego
|
LEGO_IMAGE := xenolf/lego
|
||||||
|
MAIN_DIRECTORY := ./cmd/lego/
|
||||||
|
BIN_OUTPUT := dist/lego
|
||||||
|
|
||||||
TAG_NAME := $(shell git tag -l --contains HEAD)
|
TAG_NAME := $(shell git tag -l --contains HEAD)
|
||||||
SHA := $(shell git rev-parse HEAD)
|
SHA := $(shell git rev-parse HEAD)
|
||||||
|
@ -13,7 +17,11 @@ clean:
|
||||||
|
|
||||||
build: clean
|
build: clean
|
||||||
@echo Version: $(VERSION)
|
@echo Version: $(VERSION)
|
||||||
go build -v -ldflags '-X "main.version=${VERSION}"'
|
go build -v -ldflags '-X "main.version=${VERSION}"' -o ${BIN_OUTPUT} ${MAIN_DIRECTORY}
|
||||||
|
|
||||||
|
image:
|
||||||
|
@echo Version: $(VERSION)
|
||||||
|
docker build -t $(LEGO_IMAGE) .
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
dep ensure -v
|
dep ensure -v
|
||||||
|
@ -21,9 +29,11 @@ dependencies:
|
||||||
test: clean
|
test: clean
|
||||||
go test -v -cover ./...
|
go test -v -cover ./...
|
||||||
|
|
||||||
|
e2e: clean
|
||||||
|
LEGO_E2E_TESTS=local go test -count=1 -v ./e2e/...
|
||||||
|
|
||||||
checks:
|
checks:
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
|
|
||||||
image:
|
fmt:
|
||||||
@echo Version: $(VERSION)
|
gofmt -s -l -w $(SRCS)
|
||||||
docker build -t $(LEGO_IMAGE) .
|
|
||||||
|
|
233
README.md
233
README.md
|
@ -34,7 +34,7 @@ yaourt -S lego-git
|
||||||
To install from source, just run:
|
To install from source, just run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get -u github.com/xenolf/lego
|
go get -u github.com/xenolf/lego/cmd/lego
|
||||||
```
|
```
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
@ -71,29 +71,31 @@ COMMANDS:
|
||||||
revoke Revoke a certificate
|
revoke Revoke a certificate
|
||||||
renew Renew a certificate
|
renew Renew a certificate
|
||||||
dnshelp Shows additional help for the --dns global option
|
dnshelp Shows additional help for the --dns global option
|
||||||
|
list Display certificates and accounts information.
|
||||||
help, h Shows a list of commands or help for one command
|
help, h Shows a list of commands or help for one command
|
||||||
|
|
||||||
GLOBAL OPTIONS:
|
GLOBAL OPTIONS:
|
||||||
--domains value, -d value Add a domain to the process. Can be specified multiple times.
|
--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-v02.api.letsencrypt.org/directory")
|
--server value, -s value CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. (default: "https://acme-v02.api.letsencrypt.org/directory")
|
||||||
--email value, -m value Email used for registration and recovery contact.
|
|
||||||
--filename value Filename of the generated certificate
|
|
||||||
--accept-tos, -a By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service.
|
--accept-tos, -a By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service.
|
||||||
|
--email value, -m value Email used for registration and recovery contact.
|
||||||
|
--csr value, -c value Certificate signing request filename, if an external CSR is to be used
|
||||||
--eab Use External Account Binding for account registration. Requires --kid and --hmac.
|
--eab Use External Account Binding for account registration. Requires --kid and --hmac.
|
||||||
--kid value Key identifier from External CA. Used for External Account Binding.
|
--kid value Key identifier from External CA. Used for External Account Binding.
|
||||||
--hmac value MAC key from External CA. Should be in Base64 URL Encoding without padding format. Used for External Account Binding.
|
--hmac value MAC key from External CA. Should be in Base64 URL Encoding without padding format. Used for External Account Binding.
|
||||||
--key-type value, -k value Key type to use for private keys. Supported: rsa2048, rsa4096, rsa8192, ec256, ec384 (default: "rsa2048")
|
--key-type value, -k value Key type to use for private keys. Supported: rsa2048, rsa4096, rsa8192, ec256, ec384 (default: "rsa2048")
|
||||||
|
--filename value Filename of the generated certificate
|
||||||
--path value Directory to use for storing the data (default: "./.lego")
|
--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", "tls-alpn-01".
|
--exclude value, -x value Explicitly disallow solvers by name from being used. Solvers: "http-01", "dns-01", "tls-alpn-01".
|
||||||
|
--http-timeout value Set the HTTP timeout value to a specific value in seconds. The default is 10 seconds. (default: 0)
|
||||||
--webroot value Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge
|
--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.
|
--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
|
--http value Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port
|
||||||
--tls value Set the port and interface to use for TLS based challenges to listen on. Supported: interface:port or :port
|
--tls value Set the port and interface to use for TLS 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.
|
--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-disable-cp By setting this flag to true, disables the need to wait the propagation of the TXT record to all authoritative name servers.
|
||||||
--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.
|
--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.
|
||||||
|
--dns-timeout value Set the DNS timeout value to a specific value in seconds. Used only when performing authoritative name servers queries. The default is 10 seconds. (default: 0)
|
||||||
--pem Generate a .pem file by concatenating the .key and .crt files together.
|
--pem Generate a .pem file by concatenating the .key and .crt files together.
|
||||||
--help, -h show help
|
--help, -h show help
|
||||||
--version, -v print the version
|
--version, -v print the version
|
||||||
|
@ -174,6 +176,104 @@ lego defaults to communicating with the production Let's Encrypt ACME server. If
|
||||||
lego --server=https://acme-staging-v02.api.letsencrypt.org/directory …
|
lego --server=https://acme-staging-v02.api.letsencrypt.org/directory …
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## ACME Library Usage
|
||||||
|
|
||||||
|
A valid, but bare-bones example use of the acme package:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/certcrypto"
|
||||||
|
"github.com/xenolf/lego/certificate"
|
||||||
|
"github.com/xenolf/lego/lego"
|
||||||
|
"github.com/xenolf/lego/registration"
|
||||||
|
)
|
||||||
|
|
||||||
|
// You'll need a user or account type that implements acme.User
|
||||||
|
type MyUser struct {
|
||||||
|
Email string
|
||||||
|
Registration *registration.Resource
|
||||||
|
key crypto.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *MyUser) GetEmail() string {
|
||||||
|
return u.Email
|
||||||
|
}
|
||||||
|
func (u MyUser) GetRegistration() *registration.Resource {
|
||||||
|
return u.Registration
|
||||||
|
}
|
||||||
|
func (u *MyUser) GetPrivateKey() crypto.PrivateKey {
|
||||||
|
return u.key
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
|
||||||
|
// Create a user. New accounts need an email and private key to start.
|
||||||
|
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
myUser := MyUser{
|
||||||
|
Email: "you@yours.com",
|
||||||
|
key: privateKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
config := lego.NewConfig(&myUser)
|
||||||
|
|
||||||
|
// This CA URL is configured for a local dev instance of Boulder running in Docker in a VM.
|
||||||
|
config.CADirURL = "http://192.168.99.100:4000/directory"
|
||||||
|
config.KeyType = certcrypto.RSA2048
|
||||||
|
|
||||||
|
// A client facilitates communication with the CA server.
|
||||||
|
client, err := lego.NewClient(config)
|
||||||
|
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 you still
|
||||||
|
// need to proxy challenge traffic to port 5002 and 5001.
|
||||||
|
if err = client.Challenge.SetHTTP01Address(":5002"); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = client.Challenge.SetTLSALPN01Address(":5001"); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New users will need to register
|
||||||
|
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
myUser.Registration = reg
|
||||||
|
|
||||||
|
request := certificate.ObtainRequest{
|
||||||
|
Domains: []string{"mydomain.com"},
|
||||||
|
Bundle: true,
|
||||||
|
}
|
||||||
|
certificates, err := client.Certificate.Obtain(request)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## DNS Challenge API Details
|
## DNS Challenge API Details
|
||||||
|
|
||||||
### AWS Route 53
|
### AWS Route 53
|
||||||
|
@ -183,108 +283,31 @@ Replace `<INSERT_YOUR_HOSTED_ZONE_ID_HERE>` with the Route 53 zone ID of the dom
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"Version": "2012-10-17",
|
"Version": "2012-10-17",
|
||||||
"Statement": [
|
"Statement": [
|
||||||
{
|
{
|
||||||
"Effect": "Allow",
|
"Sid": "",
|
||||||
"Action": [
|
"Effect": "Allow",
|
||||||
"route53:GetChange",
|
"Action": [
|
||||||
"route53:ListHostedZonesByName"
|
"route53:GetChange",
|
||||||
],
|
"route53:ChangeResourceRecordSets",
|
||||||
"Resource": [
|
"route53:ListResourceRecordSets"
|
||||||
"*"
|
],
|
||||||
]
|
"Resource": [
|
||||||
},
|
"arn:aws:route53:::hostedzone/*",
|
||||||
{
|
"arn:aws:route53:::change/*"
|
||||||
"Effect": "Allow",
|
]
|
||||||
"Action": [
|
},
|
||||||
"route53:ChangeResourceRecordSets"
|
{
|
||||||
],
|
"Sid": "",
|
||||||
"Resource": [
|
"Effect": "Allow",
|
||||||
"arn:aws:route53:::hostedzone/<INSERT_YOUR_HOSTED_ZONE_ID_HERE>"
|
"Action": "route53:ListHostedZonesByName",
|
||||||
]
|
"Resource": "*"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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.
|
|
||||||
```
|
|
||||||
|
|
||||||
## ACME v1
|
## ACME v1
|
||||||
|
|
||||||
lego introduced support for ACME v2 in [v1.0.0](https://github.com/xenolf/lego/releases/tag/v1.0.0), if you still need to utilize ACME v1, you can do so by using the [v0.5.0](https://github.com/xenolf/lego/releases/tag/v0.5.0) version.
|
lego introduced support for ACME v2 in [v1.0.0](https://github.com/xenolf/lego/releases/tag/v1.0.0), if you still need to utilize ACME v1, you can do so by using the [v0.5.0](https://github.com/xenolf/lego/releases/tag/v0.5.0) version.
|
||||||
|
|
134
account.go
134
account.go
|
@ -1,134 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto"
|
|
||||||
"encoding/json"
|
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"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 := filepath.Join(accKeysPath, 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 := filepath.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(
|
|
||||||
filepath.Join(a.conf.AccountPath(a.Email), "account.json"),
|
|
||||||
jsonBytes,
|
|
||||||
0600,
|
|
||||||
)
|
|
||||||
}
|
|
69
acme/api/account.go
Normal file
69
acme/api/account.go
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/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 string, 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: %v", 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: %v", err)
|
||||||
|
}
|
||||||
|
accMsg.ExternalAccountBinding = eabJWS
|
||||||
|
|
||||||
|
return a.New(accMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Retrieves an account.
|
||||||
|
func (a *AccountService) Get(accountURL string) (acme.Account, error) {
|
||||||
|
if len(accountURL) == 0 {
|
||||||
|
return acme.Account{}, errors.New("account[get]: empty URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
var account acme.Account
|
||||||
|
_, err := a.core.post(accountURL, acme.Account{}, &account)
|
||||||
|
if err != nil {
|
||||||
|
return acme.Account{}, err
|
||||||
|
}
|
||||||
|
return account, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate Deactivates an account.
|
||||||
|
func (a *AccountService) Deactivate(accountURL string) error {
|
||||||
|
if len(accountURL) == 0 {
|
||||||
|
return errors.New("account[deactivate]: empty URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
req := acme.Account{Status: acme.StatusDeactivated}
|
||||||
|
_, err := a.core.post(accountURL, req, nil)
|
||||||
|
return err
|
||||||
|
}
|
151
acme/api/api.go
Normal file
151
acme/api/api.go
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/acme/api/internal/nonces"
|
||||||
|
"github.com/xenolf/lego/acme/api/internal/secure"
|
||||||
|
"github.com/xenolf/lego/acme/api/internal/sender"
|
||||||
|
"github.com/xenolf/lego/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 string, 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}
|
||||||
|
|
||||||
|
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, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// postAsGet performs an HTTP POST ("POST-as-GET") request.
|
||||||
|
// https://tools.ietf.org/html/draft-ietf-acme-acme-16#section-6.3
|
||||||
|
func (a *Core) postAsGet(uri string, response interface{}) (*http.Response, error) {
|
||||||
|
return a.retrievablePost(uri, []byte{}, response, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Core) retrievablePost(uri string, content []byte, response interface{}, retry int) (*http.Response, error) {
|
||||||
|
resp, err := a.signedPost(uri, content, response)
|
||||||
|
if err != nil {
|
||||||
|
// during tests, 5 retries allow to support ~50% of bad nonce.
|
||||||
|
if retry >= 5 {
|
||||||
|
log.Infof("too many retry on a nonce error, retry count: %d", retry)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
switch err.(type) {
|
||||||
|
// Retry once if the nonce was invalidated
|
||||||
|
case *acme.NonceError:
|
||||||
|
log.Infof("nonce error retry: %s", err)
|
||||||
|
resp, err = a.retrievablePost(uri, content, response, retry+1)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
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 -> %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signedBody := bytes.NewBuffer([]byte(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': %v", 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
|
||||||
|
}
|
34
acme/api/authorization.go
Normal file
34
acme/api/authorization.go
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthorizationService service
|
||||||
|
|
||||||
|
// Get Gets an authorization.
|
||||||
|
func (c *AuthorizationService) Get(authzURL string) (acme.Authorization, error) {
|
||||||
|
if len(authzURL) == 0 {
|
||||||
|
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 len(authzURL) == 0 {
|
||||||
|
return errors.New("authorization[deactivate]: empty URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
var disabledAuth acme.Authorization
|
||||||
|
_, err := c.core.post(authzURL, acme.Authorization{Status: acme.StatusDeactivated}, &disabledAuth)
|
||||||
|
return err
|
||||||
|
}
|
99
acme/api/certificate.go
Normal file
99
acme/api/certificate.go
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/certcrypto"
|
||||||
|
"github.com/xenolf/lego/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, up, err := c.get(certURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
return cert, issuer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 cert, issuer, 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) ([]byte, string, error) {
|
||||||
|
if len(certURL) == 0 {
|
||||||
|
return nil, "", errors.New("certificate[get]: empty URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.core.postAsGet(certURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := ioutil.ReadAll(http.MaxBytesReader(nil, resp.Body, maxBodySize))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The issuer certificate link may be supplied via an "up" link
|
||||||
|
// in the response headers of a new certificate.
|
||||||
|
// See https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.4.2
|
||||||
|
up := getLink(resp.Header, "up")
|
||||||
|
|
||||||
|
return cert, up, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// getIssuerFromLink requests the issuer certificate
|
||||||
|
func (c *CertificateService) getIssuerFromLink(up string) ([]byte, error) {
|
||||||
|
if len(up) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("acme: Requesting issuer cert from %s", up)
|
||||||
|
|
||||||
|
cert, _, err := c.get(up)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = x509.ParseCertificate(cert)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return certcrypto.PEMEncode(certcrypto.DERCertificateBytes(cert)), nil
|
||||||
|
}
|
129
acme/api/certificate_test.go
Normal file
129
acme/api/certificate_test.go
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/pem"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/xenolf/lego/platform/tester"
|
||||||
|
)
|
||||||
|
|
||||||
|
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, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
mux.HandleFunc("/certificate", func(w http.ResponseWriter, r *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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/issuer", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, _ := pem.Decode([]byte(issuerMock))
|
||||||
|
_, err := w.Write(p.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
mux.HandleFunc("/certificate", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, err := w.Write([]byte(certResponseMock))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
45
acme/api/challenge.go
Normal file
45
acme/api/challenge.go
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChallengeService service
|
||||||
|
|
||||||
|
// New Creates a challenge.
|
||||||
|
func (c *ChallengeService) New(chlgURL string) (acme.ExtendedChallenge, error) {
|
||||||
|
if len(chlgURL) == 0 {
|
||||||
|
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 len(chlgURL) == 0 {
|
||||||
|
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
|
||||||
|
}
|
78
acme/api/internal/nonces/nonce_manager.go
Normal file
78
acme/api/internal/nonces/nonce_manager.go
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
package nonces
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/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 -> %v", 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 "", fmt.Errorf("server did not respond with a proper nonce header")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nonce, nil
|
||||||
|
}
|
55
acme/api/internal/nonces/nonce_manager_test.go
Normal file
55
acme/api/internal/nonces/nonce_manager_test.go
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
package nonces
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/acme/api/internal/sender"
|
||||||
|
"github.com/xenolf/lego/platform/tester"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
doer := sender.NewDoer(http.DefaultClient, "lego-test")
|
||||||
|
j := NewManager(doer, ts.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(400 * time.Millisecond):
|
||||||
|
t.Fatal("JWS is probably holding a lock while making HTTP request")
|
||||||
|
}
|
||||||
|
}
|
134
acme/api/internal/secure/jws.go
Normal file
134
acme/api/internal/secure/jws.go
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
package secure
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme/api/internal/nonces"
|
||||||
|
"gopkg.in/square/go-jose.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 -> %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signed, err := signer.Sign(content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to sign content -> %v", 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: %v", 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 -> %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signed, err := signer.Sign(jwkJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to External Account Binding sign content -> %v", 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}
|
||||||
|
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
|
||||||
|
}
|
56
acme/api/internal/secure/jws_test.go
Normal file
56
acme/api/internal/secure/jws_test.go
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
package secure
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/acme/api/internal/nonces"
|
||||||
|
"github.com/xenolf/lego/acme/api/internal/sender"
|
||||||
|
"github.com/xenolf/lego/platform/tester"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
doer := sender.NewDoer(http.DefaultClient, "lego-test")
|
||||||
|
j := nonces.NewManager(doer, ts.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(400 * time.Millisecond):
|
||||||
|
t.Fatal("JWS is probably holding a lock while making HTTP request")
|
||||||
|
}
|
||||||
|
}
|
146
acme/api/internal/sender/sender.go
Normal file
146
acme/api/internal/sender/sender.go
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
package sender
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/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: %v", 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: %v", 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 := ioutil.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: %v", 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 := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%d :: %s :: %s :: %v", 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 :: %v :: %s", resp.StatusCode, req.Method, req.URL, err, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
errorDetails.Method = req.Method
|
||||||
|
errorDetails.URL = req.URL.String()
|
||||||
|
|
||||||
|
// Check for errors we handle specifically
|
||||||
|
if errorDetails.HTTPStatus == http.StatusBadRequest && errorDetails.Type == acme.BadNonceErr {
|
||||||
|
return &acme.NonceError{ProblemDetails: errorDetails}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorDetails
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
68
acme/api/internal/sender/sender_test.go
Normal file
68
acme/api/internal/sender/sender_test.go
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
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
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ua = r.Header.Get("User-Agent")
|
||||||
|
method = r.Method
|
||||||
|
}))
|
||||||
|
defer ts.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(ts.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)
|
||||||
|
}
|
11
acme/api/internal/sender/useragent.go
Normal file
11
acme/api/internal/sender/useragent.go
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
package sender
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ourUserAgent is the User-Agent of this underlying library package.
|
||||||
|
ourUserAgent = "xenolf-acme"
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
)
|
65
acme/api/order.go
Normal file
65
acme/api/order.go
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrderService service
|
||||||
|
|
||||||
|
// New Creates a new order.
|
||||||
|
func (o *OrderService) New(domains []string) (acme.ExtendedOrder, error) {
|
||||||
|
var identifiers []acme.Identifier
|
||||||
|
for _, domain := range domains {
|
||||||
|
identifiers = append(identifiers, acme.Identifier{Type: "dns", Value: domain})
|
||||||
|
}
|
||||||
|
|
||||||
|
orderReq := acme.Order{Identifiers: identifiers}
|
||||||
|
|
||||||
|
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{
|
||||||
|
Location: resp.Header.Get("Location"),
|
||||||
|
Order: order,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Gets an order.
|
||||||
|
func (o *OrderService) Get(orderURL string) (acme.Order, error) {
|
||||||
|
if len(orderURL) == 0 {
|
||||||
|
return acme.Order{}, errors.New("order[get]: empty URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
var order acme.Order
|
||||||
|
_, err := o.core.postAsGet(orderURL, &order)
|
||||||
|
if err != nil {
|
||||||
|
return acme.Order{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateForCSR Updates an order for a CSR.
|
||||||
|
func (o *OrderService) UpdateForCSR(orderURL string, csr []byte) (acme.Order, 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.Order{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.Status == acme.StatusInvalid {
|
||||||
|
return acme.Order{}, order.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return order, nil
|
||||||
|
}
|
89
acme/api/order_test.go
Normal file
89
acme/api/order_test.go
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/platform/tester"
|
||||||
|
"gopkg.in/square/go-jose.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOrderService_New(t *testing.T) {
|
||||||
|
mux, apiURL, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
Identifiers: order.Identifiers,
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
|
||||||
|
order, err := core.Orders.New([]string{"example.com"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expected := acme.ExtendedOrder{
|
||||||
|
Order: acme.Order{
|
||||||
|
Status: "valid",
|
||||||
|
Identifiers: []acme.Identifier{{Type: "dns", Value: "example.com"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.Equal(t, expected, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSignedBody(r *http.Request, privateKey *rsa.PrivateKey) ([]byte, error) {
|
||||||
|
reqBody, err := ioutil.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
|
||||||
|
}
|
45
acme/api/service.go
Normal file
45
acme/api/service.go
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
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 {
|
||||||
|
var linkExpr = regexp.MustCompile(`<(.+?)>;\s*rel="(.+?)"`)
|
||||||
|
|
||||||
|
for _, link := range header["Link"] {
|
||||||
|
for _, m := range linkExpr.FindAllStringSubmatch(link, -1) {
|
||||||
|
if len(m) != 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m[2] == rel {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
56
acme/api/service_test.go
Normal file
56
acme/api/service_test.go
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,17 +0,0 @@
|
||||||
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")
|
|
||||||
|
|
||||||
// TLSALPN01 is the "tls-alpn-01" ACME challenge https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01
|
|
||||||
TLSALPN01 = Challenge("tls-alpn-01")
|
|
||||||
)
|
|
957
acme/client.go
957
acme/client.go
|
@ -1,957 +0,0 @@
|
||||||
// Package acme implements the ACME protocol for Let's Encrypt and other conforming providers.
|
|
||||||
package acme
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/pem"
|
|
||||||
"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
|
|
||||||
|
|
||||||
statusValid = "valid"
|
|
||||||
statusInvalid = "invalid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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(challenge challenge, domain string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interface for challenges like dns, where we can solve all the challenges before to delete them.
|
|
||||||
type cleanup interface {
|
|
||||||
CleanUp(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 := map[Challenge]solver{
|
|
||||||
HTTP01: &httpChallenge{jws: jws, validate: validate, provider: &HTTPProviderServer{}},
|
|
||||||
TLSALPN01: &tlsALPNChallenge{jws: jws, validate: validate, provider: &TLSALPNProviderServer{}},
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
case TLSALPN01:
|
|
||||||
c.solvers[challenge] = &tlsALPNChallenge{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
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetTLSAddress specifies a custom interface:port to be used for TLS based challenges.
|
|
||||||
// If this option is not used, the default port 443 and all interfaces will be used.
|
|
||||||
// To only specify a port and no interface use the ":port" notation.
|
|
||||||
//
|
|
||||||
// NOTE: This REPLACES any custom TLS-ALPN provider previously set by calling
|
|
||||||
// c.SetChallengeProvider with the default TLS-ALPN challenge provider.
|
|
||||||
func (c *Client) SetTLSAddress(iface string) error {
|
|
||||||
host, port, err := net.SplitHostPort(iface)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if chlng, ok := c.solvers[TLSALPN01]; ok {
|
|
||||||
chlng.(*tlsALPNChallenge).provider = NewTLSALPNProviderServer(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.Infof("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
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterWithExternalAccountBinding 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.Infof("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.Infof("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.Infof("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.Infof("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.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, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
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.Infof("[%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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cert != nil {
|
|
||||||
// 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.Infof("[%s] acme: Obtaining bundled SAN certificate", strings.Join(domains, ", "))
|
|
||||||
} else {
|
|
||||||
log.Infof("[%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.Infof("[%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.Infof("[%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, errP := pemDecodeTox509CSR(cert.CSR)
|
|
||||||
if errP != nil {
|
|
||||||
return nil, errP
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// an authz with the solver we have chosen and the index of the challenge associated with it
|
|
||||||
type selectedAuthSolver struct {
|
|
||||||
authz authorization
|
|
||||||
challengeIndex int
|
|
||||||
solver solver
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
authSolvers := []*selectedAuthSolver{}
|
|
||||||
|
|
||||||
// loop through the resources, basically through the domains. First pass just selects a solver for each authz.
|
|
||||||
for _, authz := range authorizations {
|
|
||||||
if authz.Status == statusValid {
|
|
||||||
// Boulder might recycle recent validated authz (see issue #267)
|
|
||||||
log.Infof("[%s] acme: Authorization already valid; skipping challenge", authz.Identifier.Value)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if i, solvr := c.chooseSolver(authz, authz.Identifier.Value); solvr != nil {
|
|
||||||
authSolvers = append(authSolvers, &selectedAuthSolver{
|
|
||||||
authz: authz,
|
|
||||||
challengeIndex: i,
|
|
||||||
solver: solvr,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
failures[authz.Identifier.Value] = fmt.Errorf("[%s] acme: Could not determine solvers", authz.Identifier.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// for all valid presolvers, first submit the challenges so they have max time to propagate
|
|
||||||
for _, item := range authSolvers {
|
|
||||||
authz := item.authz
|
|
||||||
i := item.challengeIndex
|
|
||||||
if presolver, ok := item.solver.(preSolver); ok {
|
|
||||||
if err := presolver.PreSolve(authz.Challenges[i], authz.Identifier.Value); err != nil {
|
|
||||||
failures[authz.Identifier.Value] = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
// clean all created TXT records
|
|
||||||
for _, item := range authSolvers {
|
|
||||||
if clean, ok := item.solver.(cleanup); ok {
|
|
||||||
if failures[item.authz.Identifier.Value] != nil {
|
|
||||||
// already failed in previous loop
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
err := clean.CleanUp(item.authz.Challenges[item.challengeIndex], item.authz.Identifier.Value)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("Error cleaning up %s: %v ", item.authz.Identifier.Value, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// finally solve all challenges for real
|
|
||||||
for _, item := range authSolvers {
|
|
||||||
authz := item.authz
|
|
||||||
i := item.challengeIndex
|
|
||||||
if failures[authz.Identifier.Value] != nil {
|
|
||||||
// already failed in previous loop
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := item.solver.Solve(authz.Challenges[i], authz.Identifier.Value); err != nil {
|
|
||||||
failures[authz.Identifier.Value] = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.Infof("[%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 := postAsGet(c.jws, 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.Infof("[%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]
|
|
||||||
|
|
||||||
// ACME draft Section 7.4 "Applying for Certificate Issuance"
|
|
||||||
// https://tools.ietf.org/html/draft-ietf-acme-acme-12#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 := 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
|
|
||||||
_, err := postJSON(c.jws, order.Finalize, csrMessage{Csr: csrString}, &retOrder)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if retOrder.Status == statusInvalid {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
certRes := CertificateResource{
|
|
||||||
Domain: commonName,
|
|
||||||
CertURL: retOrder.Certificate,
|
|
||||||
PrivateKey: privateKeyPem,
|
|
||||||
}
|
|
||||||
|
|
||||||
if retOrder.Status == statusValid {
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stopTimer := time.NewTimer(30 * time.Second)
|
|
||||||
defer stopTimer.Stop()
|
|
||||||
retryTick := time.NewTicker(500 * time.Millisecond)
|
|
||||||
defer retryTick.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stopTimer.C:
|
|
||||||
return nil, errors.New("certificate polling timed out")
|
|
||||||
case <-retryTick.C:
|
|
||||||
_, err := postAsGet(c.jws, order.URL, &retOrder)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
done, err := c.checkCertResponse(retOrder, &certRes, bundle)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if done {
|
|
||||||
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 statusValid:
|
|
||||||
resp, err := postAsGet(c.jws, order.Certificate, nil)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cert, err := ioutil.ReadAll(limitReader(resp.Body, maxBodySize))
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// The issuer certificate link may be supplied via an "up" link
|
|
||||||
// in the response headers of a new certificate. See
|
|
||||||
// https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.4.2
|
|
||||||
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.Warnf("[%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
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Get issuerCert from bundled response from Let's Encrypt
|
|
||||||
// See https://community.letsencrypt.org/t/acme-v2-no-up-link-in-response/64962
|
|
||||||
_, rest := pem.Decode(cert)
|
|
||||||
if rest != nil {
|
|
||||||
certRes.IssuerCertificate = rest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
certRes.Certificate = cert
|
|
||||||
certRes.CertURL = order.Certificate
|
|
||||||
certRes.CertStableURL = order.Certificate
|
|
||||||
log.Infof("[%s] Server responded with a certificate.", certRes.Domain)
|
|
||||||
return true, nil
|
|
||||||
|
|
||||||
case "processing":
|
|
||||||
return false, nil
|
|
||||||
case statusInvalid:
|
|
||||||
return false, errors.New("order has invalid state: invalid")
|
|
||||||
default:
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getIssuerCertificate requests the issuer certificate
|
|
||||||
func (c *Client) getIssuerCertificate(url string) ([]byte, error) {
|
|
||||||
log.Infof("acme: Requesting issuer cert from %s", url)
|
|
||||||
resp, err := postAsGet(c.jws, url, nil)
|
|
||||||
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
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
hdr, err := postJSON(j, uri, struct{}{}, &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 statusValid:
|
|
||||||
log.Infof("[%s] The server validated our request", domain)
|
|
||||||
return nil
|
|
||||||
case "pending":
|
|
||||||
case "processing":
|
|
||||||
case statusInvalid:
|
|
||||||
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)
|
|
||||||
|
|
||||||
resp, err := postAsGet(j, uri, &chlng)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if resp != nil {
|
|
||||||
hdr = resp.Header
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,378 +0,0 @@
|
||||||
package acme
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io/ioutil"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gopkg.in/square/go-jose.v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewClient(t *testing.T) {
|
|
||||||
keyBits := 32 // small value keeps test fast
|
|
||||||
keyType := RSA2048
|
|
||||||
key, err := rsa.GenerateKey(rand.Reader, keyBits)
|
|
||||||
require.NoError(t, err, "Could not generate test key")
|
|
||||||
|
|
||||||
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",
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err = w.Write(data)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
client, err := NewClient(ts.URL, user, keyType)
|
|
||||||
require.NoError(t, err, "Could not create client")
|
|
||||||
|
|
||||||
require.NotNil(t, client.jws, "client.jws")
|
|
||||||
assert.Equal(t, key, client.jws.privKey, "client.jws.privKey")
|
|
||||||
assert.Equal(t, keyType, client.keyType, "client.keyType")
|
|
||||||
assert.Len(t, client.solvers, 2, "solvers")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientOptPort(t *testing.T) {
|
|
||||||
keyBits := 32 // small value keeps test fast
|
|
||||||
key, err := rsa.GenerateKey(rand.Reader, keyBits)
|
|
||||||
require.NoError(t, err, "Could not generate test key")
|
|
||||||
|
|
||||||
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",
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err = w.Write(data)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
optPort := "1234"
|
|
||||||
optHost := ""
|
|
||||||
|
|
||||||
client, err := NewClient(ts.URL, user, RSA2048)
|
|
||||||
require.NoError(t, err, "Could not create client")
|
|
||||||
|
|
||||||
err = client.SetHTTPAddress(net.JoinHostPort(optHost, optPort))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.IsType(t, &httpChallenge{}, client.solvers[HTTP01])
|
|
||||||
httpSolver := client.solvers[HTTP01].(*httpChallenge)
|
|
||||||
|
|
||||||
assert.Equal(t, httpSolver.jws, client.jws, "Expected http-01 to have same jws as client")
|
|
||||||
|
|
||||||
httpProviderServer := httpSolver.provider.(*HTTPProviderServer)
|
|
||||||
assert.Equal(t, optPort, httpProviderServer.port, "port")
|
|
||||||
assert.Equal(t, optHost, httpProviderServer.iface, "iface")
|
|
||||||
|
|
||||||
// test setting different host
|
|
||||||
optHost = "127.0.0.1"
|
|
||||||
err = client.SetHTTPAddress(net.JoinHostPort(optHost, optPort))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, optHost, httpSolver.provider.(*HTTPProviderServer).iface, "iface")
|
|
||||||
}
|
|
||||||
|
|
||||||
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, err := rsa.GenerateKey(rand.Reader, 512)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
j := &jws{privKey: privKey, getNonceURL: ts.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(400 * time.Millisecond):
|
|
||||||
t.Fatal("JWS is probably holding a lock while making HTTP request")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidate(t *testing.T) {
|
|
||||||
var statuses []string
|
|
||||||
|
|
||||||
privKey, err := rsa.GenerateKey(rand.Reader, 512)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
validateNoBody := func(r *http.Request) error {
|
|
||||||
reqBody, err := ioutil.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: privKey.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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 http.MethodHead:
|
|
||||||
case http.MethodPost:
|
|
||||||
if err := validateNoBody(r); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
st := statuses[0]
|
|
||||||
statuses = statuses[1:]
|
|
||||||
writeJSONResponse(w, &challenge{Type: "http-01", Status: st, URL: "http://example.com/", Token: "token"})
|
|
||||||
|
|
||||||
case http.MethodGet:
|
|
||||||
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()
|
|
||||||
|
|
||||||
j := &jws{privKey: privKey, getNonceURL: ts.URL}
|
|
||||||
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
statuses []string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "POST-unexpected",
|
|
||||||
statuses: []string{"weird"},
|
|
||||||
want: "unexpected",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "POST-valid",
|
|
||||||
statuses: []string{"valid"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "POST-invalid",
|
|
||||||
statuses: []string{"invalid"},
|
|
||||||
want: "Error",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "GET-unexpected",
|
|
||||||
statuses: []string{"pending", "weird"},
|
|
||||||
want: "unexpected",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "GET-valid",
|
|
||||||
statuses: []string{"pending", "valid"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "GET-invalid",
|
|
||||||
statuses: []string{"pending", "invalid"},
|
|
||||||
want: "Error",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range testCases {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
statuses = test.statuses
|
|
||||||
|
|
||||||
err := validate(j, "example.com", ts.URL, challenge{Type: "http-01", Token: "token"})
|
|
||||||
if test.want == "" {
|
|
||||||
require.NoError(t, err)
|
|
||||||
} else {
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), test.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 http.MethodGet, http.MethodHead:
|
|
||||||
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 http.MethodPost:
|
|
||||||
writeJSONResponse(w, orderMessage{})
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
keyBits := 512 // small value keeps test fast
|
|
||||||
keyType := RSA2048
|
|
||||||
|
|
||||||
key, err := rsa.GenerateKey(rand.Reader, keyBits)
|
|
||||||
require.NoError(t, err, "Could not generate test key")
|
|
||||||
|
|
||||||
user := mockUser{
|
|
||||||
email: "test@test.com",
|
|
||||||
regres: &RegistrationResource{URI: ts.URL},
|
|
||||||
privatekey: key,
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := NewClient(ts.URL, user, keyType)
|
|
||||||
require.NoError(t, err, "Could not create client")
|
|
||||||
|
|
||||||
_, err = client.createOrderForIdentifiers([]string{"example.com"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveAccountByKey(t *testing.T) {
|
|
||||||
keyBits := 512
|
|
||||||
keyType := RSA2048
|
|
||||||
|
|
||||||
key, err := rsa.GenerateKey(rand.Reader, keyBits)
|
|
||||||
require.NoError(t, err, "Could not generate test key")
|
|
||||||
|
|
||||||
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)
|
|
||||||
require.NoError(t, err, "Could not create client")
|
|
||||||
|
|
||||||
res, err := client.ResolveAccountByKey()
|
|
||||||
require.NoError(t, err, "Unexpected error resolving account by key")
|
|
||||||
|
|
||||||
assert.Equal(t, "valid", res.Body.Status, "Unexpected account 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(_ *jws, _, _ string, _ 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 }
|
|
284
acme/commons.go
Normal file
284
acme/commons.go
Normal file
|
@ -0,0 +1,284 @@
|
||||||
|
// Package acme contains all objects related the ACME endpoints.
|
||||||
|
// https://tools.ietf.org/html/draft-ietf-acme-acme-16
|
||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Challenge statuses
|
||||||
|
// https://tools.ietf.org/html/draft-ietf-acme-acme-16#section-7.1.6
|
||||||
|
const (
|
||||||
|
StatusPending = "pending"
|
||||||
|
StatusInvalid = "invalid"
|
||||||
|
StatusValid = "valid"
|
||||||
|
StatusProcessing = "processing"
|
||||||
|
StatusDeactivated = "deactivated"
|
||||||
|
StatusExpired = "expired"
|
||||||
|
StatusRevoked = "revoked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Directory the ACME directory object.
|
||||||
|
// - https://tools.ietf.org/html/draft-ietf-acme-acme-16#section-7.1.1
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta the ACME meta object (related to Directory).
|
||||||
|
// - https://tools.ietf.org/html/draft-ietf-acme-acme-16#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://tools.ietf.org/html/draft-ietf-acme-acme-16#section-7.1.2
|
||||||
|
// - https://tools.ietf.org/html/draft-ietf-acme-acme-16#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://tools.ietf.org/html/draft-ietf-acme-acme-16#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://tools.ietf.org/html/draft-ietf-acme-acme-16#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://tools.ietf.org/html/draft-ietf-acme-acme-16#section-7.1.5
|
||||||
|
// - https://tools.ietf.org/html/draft-ietf-acme-acme-16#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://tools.ietf.org/html/draft-ietf-acme-acme-16#section-8.3
|
||||||
|
// https://tools.ietf.org/html/draft-ietf-acme-acme-16#section-8.4
|
||||||
|
Token string `json:"token"`
|
||||||
|
|
||||||
|
// https://tools.ietf.org/html/draft-ietf-acme-acme-16#section-8.1
|
||||||
|
KeyAuthorization string `json:"keyAuthorization"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifier the ACME identifier object.
|
||||||
|
// - https://tools.ietf.org/html/draft-ietf-acme-acme-16#section-9.7.7
|
||||||
|
type Identifier struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRMessage Certificate Signing Request
|
||||||
|
// - https://tools.ietf.org/html/draft-ietf-acme-acme-16#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://tools.ietf.org/html/draft-ietf-acme-acme-16#section-7.6
|
||||||
|
// - https://tools.ietf.org/html/rfc5280#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"`
|
||||||
|
}
|
334
acme/crypto.go
334
acme/crypto.go
|
@ -1,334 +0,0 @@
|
||||||
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, errC := httpGet(issuedCert.IssuingCertificateURL[0])
|
|
||||||
if errC != nil {
|
|
||||||
return nil, nil, errC
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
issuerBytes, errC := ioutil.ReadAll(limitReader(resp.Body, 1024*1024))
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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, extensions []pkix.Extension) ([]byte, error) {
|
|
||||||
derBytes, err := generateDerCert(privKey, time.Time{}, domain, extensions)
|
|
||||||
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, 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().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},
|
|
||||||
ExtraExtensions: extensions,
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
|
@ -1,76 +0,0 @@
|
||||||
package acme
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"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) {
|
|
||||||
key, err := rsa.GenerateKey(rand.Reader, 512)
|
|
||||||
require.NoError(t, err, "Error generating private key")
|
|
||||||
|
|
||||||
csr, err := generateCsr(key, "fizz.buzz", nil, true)
|
|
||||||
require.NoError(t, err, "Error generating CSR")
|
|
||||||
|
|
||||||
assert.NotEmpty(t, csr)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
assert.Len(t, data, 127)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPEMCertExpiration(t *testing.T) {
|
|
||||||
privKey, err := generatePrivateKey(RSA2048)
|
|
||||||
require.NoError(t, err, "Error generating private key")
|
|
||||||
|
|
||||||
expiration := time.Now().Add(365)
|
|
||||||
expiration = expiration.Round(time.Second)
|
|
||||||
certBytes, err := generateDerCert(privKey.(*rsa.PrivateKey), expiration, "test.com", nil)
|
|
||||||
require.NoError(t, err, "Error generating cert")
|
|
||||||
|
|
||||||
buf := bytes.NewBufferString("TestingRSAIsSoMuchFun")
|
|
||||||
|
|
||||||
// Some random string should return an error.
|
|
||||||
ctime, err := GetPEMCertExpiration(buf.Bytes())
|
|
||||||
require.Errorf(t, err, "Expected getCertExpiration to return an error for garbage string but returned %v", ctime)
|
|
||||||
|
|
||||||
// A DER encoded certificate should return an error.
|
|
||||||
_, err = GetPEMCertExpiration(certBytes)
|
|
||||||
require.Error(t, err, "Expected getCertExpiration to return an error for DER certificates")
|
|
||||||
|
|
||||||
// A PEM encoded certificate should work ok.
|
|
||||||
pemCert := pemEncode(derCertificateBytes(certBytes))
|
|
||||||
ctime, err = GetPEMCertExpiration(pemCert)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, expiration.UTC(), ctime)
|
|
||||||
}
|
|
||||||
|
|
||||||
type MockRandReader struct {
|
|
||||||
b *bytes.Buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MockRandReader) Read(p []byte) (int, error) {
|
|
||||||
return r.b.Read(p)
|
|
||||||
}
|
|
|
@ -1,343 +0,0 @@
|
||||||
package acme
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"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{}
|
|
||||||
muFqdnToZone sync.Mutex
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultResolvConf = "/etc/resolv.conf"
|
|
||||||
|
|
||||||
const (
|
|
||||||
// DefaultPropagationTimeout default propagation timeout
|
|
||||||
DefaultPropagationTimeout = 60 * time.Second
|
|
||||||
|
|
||||||
// DefaultPollingInterval default polling interval
|
|
||||||
DefaultPollingInterval = 2 * time.Second
|
|
||||||
|
|
||||||
// DefaultTTL default TTL
|
|
||||||
DefaultTTL = 120
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
// 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 = DefaultTTL
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (s *dnsChallenge) PreSolve(chlng challenge, domain string) error {
|
|
||||||
log.Infof("[%s] acme: Preparing 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *dnsChallenge) Solve(chlng challenge, domain string) error {
|
|
||||||
log.Infof("[%s] acme: Trying to solve DNS-01", domain)
|
|
||||||
|
|
||||||
// Generate the Key Authorization for the challenge
|
|
||||||
keyAuth, err := getKeyAuthorization(chlng.Token, s.jws.privKey)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fqdn, value, _ := DNS01Record(domain, keyAuth)
|
|
||||||
|
|
||||||
log.Infof("[%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 = DefaultPropagationTimeout, DefaultPollingInterval
|
|
||||||
}
|
|
||||||
|
|
||||||
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})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CleanUp cleans the challenge
|
|
||||||
func (s *dnsChallenge) CleanUp(chlng challenge, domain string) error {
|
|
||||||
keyAuth, err := getKeyAuthorization(chlng.Token, s.jws.privKey)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.provider.CleanUp(domain, chlng.Token, 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 [fqdn: %s]", ns, fqdn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
muFqdnToZone.Lock()
|
|
||||||
defer muFqdnToZone.Unlock()
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
|
@ -1,55 +0,0 @@
|
||||||
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.Infof("acme: Please create the following TXT record in your %s zone:", authZone)
|
|
||||||
log.Infof("acme: %s", dnsRecord)
|
|
||||||
log.Infof("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.Infof("acme: You can now remove this TXT record from your %s zone:", authZone)
|
|
||||||
log.Infof("acme: %s", dnsRecord)
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -1,321 +0,0 @@
|
||||||
package acme
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"sort"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDNSValidServerResponse(t *testing.T) {
|
|
||||||
PreCheckDNS = func(fqdn, value string) (bool, error) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
privKey, err := rsa.GenerateKey(rand.Reader, 512)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Add("Replay-Nonce", "12345")
|
|
||||||
|
|
||||||
_, err = w.Write([]byte("{\"type\":\"dns01\",\"status\":\"valid\",\"uri\":\"http://some.url\",\"token\":\"http8\"}"))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
time.Sleep(time.Second * 2)
|
|
||||||
f := bufio.NewWriter(os.Stdout)
|
|
||||||
defer f.Flush()
|
|
||||||
_, _ = f.WriteString("\n")
|
|
||||||
}()
|
|
||||||
|
|
||||||
manualProvider, err := NewDNSProviderManual()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
clientChallenge := challenge{Type: "dns01", Status: "pending", URL: ts.URL, Token: "http8"}
|
|
||||||
|
|
||||||
solver := &dnsChallenge{
|
|
||||||
jws: &jws{privKey: privKey, getNonceURL: ts.URL},
|
|
||||||
validate: validate,
|
|
||||||
provider: manualProvider,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = solver.Solve(clientChallenge, "example.com")
|
|
||||||
require.NoError(t, 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) {
|
|
||||||
testCases := []struct {
|
|
||||||
fqdn string
|
|
||||||
nss []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
fqdn: "books.google.com.ng.",
|
|
||||||
nss: []string{"ns1.google.com.", "ns2.google.com.", "ns3.google.com.", "ns4.google.com."},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fqdn: "www.google.com.",
|
|
||||||
nss: []string{"ns1.google.com.", "ns2.google.com.", "ns3.google.com.", "ns4.google.com."},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fqdn: "physics.georgetown.edu.",
|
|
||||||
nss: []string{"ns1.georgetown.edu.", "ns2.georgetown.edu.", "ns3.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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindZoneByFqdn(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
desc string
|
|
||||||
fqdn string
|
|
||||||
zone string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
desc: "domain is a CNAME",
|
|
||||||
fqdn: "mail.google.com.",
|
|
||||||
zone: "google.com.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
desc: "domain is a non-existent subdomain",
|
|
||||||
fqdn: "foo.google.com.",
|
|
||||||
zone: "google.com.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
desc: "domain is a eTLD",
|
|
||||||
fqdn: "example.com.ac.",
|
|
||||||
zone: "ac.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
desc: "domain is a cross-zone CNAME",
|
|
||||||
fqdn: "cross-zone-example.assets.sh.",
|
|
||||||
zone: "assets.sh.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range testCases {
|
|
||||||
test := test
|
|
||||||
t.Run(test.desc, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
zone, err := FindZoneByFqdn(test.fqdn, RecursiveNameservers)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, test.zone, zone)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
_, err := checkAuthoritativeNss(test.fqdn, test.value, test.ns)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), test.error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveConfServers(t *testing.T) {
|
|
||||||
var testCases = []struct {
|
|
||||||
fixture string
|
|
||||||
expected []string
|
|
||||||
defaults []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
fixture: "testdata/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: "testdata/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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,91 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
58
acme/errors.go
Normal file
58
acme/errors.go
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Errors types
|
||||||
|
const (
|
||||||
|
errNS = "urn:ietf:params:acme:error:"
|
||||||
|
BadNonceErr = errNS + "badNonce"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProblemDetails the problem details object
|
||||||
|
// - https://tools.ietf.org/html/rfc7807#section-3.1
|
||||||
|
// - https://tools.ietf.org/html/draft-ietf-acme-acme-16#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://tools.ietf.org/html/draft-ietf-acme-acme-16#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 len(p.Method) != 0 || len(p.URL) != 0 {
|
||||||
|
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 len(p.Instance) == 0 {
|
||||||
|
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
|
||||||
|
}
|
212
acme/http.go
212
acme/http.go
|
@ -1,212 +0,0 @@
|
||||||
package acme
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// UserAgent (if non-empty) will be tacked onto the User-Agent string in requests.
|
|
||||||
UserAgent string
|
|
||||||
|
|
||||||
// HTTPClient is an HTTP client with a reasonable timeout value and
|
|
||||||
// potentially a custom *x509.CertPool based on the caCertificatesEnvVar
|
|
||||||
// environment variable (see the `initCertPool` function)
|
|
||||||
HTTPClient = http.Client{
|
|
||||||
Transport: &http.Transport{
|
|
||||||
Proxy: http.ProxyFromEnvironment,
|
|
||||||
DialContext: (&net.Dialer{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
KeepAlive: 30 * time.Second,
|
|
||||||
}).DialContext,
|
|
||||||
TLSHandshakeTimeout: 15 * time.Second,
|
|
||||||
ResponseHeaderTimeout: 15 * time.Second,
|
|
||||||
ExpectContinueTimeout: 1 * time.Second,
|
|
||||||
TLSClientConfig: &tls.Config{
|
|
||||||
ServerName: os.Getenv(caServerNameEnvVar),
|
|
||||||
RootCAs: initCertPool(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ourUserAgent is the User-Agent of this underlying library package.
|
|
||||||
// NOTE: Update this with each tagged release.
|
|
||||||
ourUserAgent = "xenolf-acme/1.2.1"
|
|
||||||
|
|
||||||
// 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"
|
|
||||||
|
|
||||||
// caCertificatesEnvVar is the environment variable name that can be used to
|
|
||||||
// specify the path to PEM encoded CA Certificates that can be used to
|
|
||||||
// authenticate an ACME server with a HTTPS certificate not issued by a CA in
|
|
||||||
// the system-wide trusted root list.
|
|
||||||
caCertificatesEnvVar = "LEGO_CA_CERTIFICATES"
|
|
||||||
|
|
||||||
// caServerNameEnvVar is the environment variable name that can be used to
|
|
||||||
// specify the CA server name that can be used to
|
|
||||||
// authenticate an ACME server with a HTTPS certificate not issued by a CA in
|
|
||||||
// the system-wide trusted root list.
|
|
||||||
caServerNameEnvVar = "LEGO_CA_SERVER_NAME"
|
|
||||||
)
|
|
||||||
|
|
||||||
// initCertPool creates a *x509.CertPool populated with the PEM certificates
|
|
||||||
// found in the filepath specified in the caCertificatesEnvVar OS environment
|
|
||||||
// variable. If the caCertificatesEnvVar is not set then initCertPool will
|
|
||||||
// return nil. If there is an error creating a *x509.CertPool from the provided
|
|
||||||
// caCertificatesEnvVar value then initCertPool will panic.
|
|
||||||
func initCertPool() *x509.CertPool {
|
|
||||||
if customCACertsPath := os.Getenv(caCertificatesEnvVar); customCACertsPath != "" {
|
|
||||||
customCAs, err := ioutil.ReadFile(customCACertsPath)
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Sprintf("error reading %s=%q: %v",
|
|
||||||
caCertificatesEnvVar, customCACertsPath, err))
|
|
||||||
}
|
|
||||||
certPool := x509.NewCertPool()
|
|
||||||
if ok := certPool.AppendCertsFromPEM(customCAs); !ok {
|
|
||||||
panic(fmt.Sprintf("error creating x509 cert pool from %s=%q: %v",
|
|
||||||
caCertificatesEnvVar, customCACertsPath, err))
|
|
||||||
}
|
|
||||||
return certPool
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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(http.MethodHead, 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(http.MethodPost, 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(http.MethodGet, 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 := post(j, uri, jsonBytes, respBody)
|
|
||||||
if resp == nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
return resp.Header, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func postAsGet(j *jws, uri string, respBody interface{}) (*http.Response, error) {
|
|
||||||
return post(j, uri, []byte{}, respBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
func post(j *jws, uri string, reqBody []byte, respBody interface{}) (*http.Response, error) {
|
|
||||||
resp, err := j.post(uri, reqBody)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to post JWS message. -> %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= http.StatusBadRequest {
|
|
||||||
err = handleHTTPError(resp)
|
|
||||||
switch err.(type) {
|
|
||||||
case NonceError:
|
|
||||||
// Retry once if the nonce was invalidated
|
|
||||||
|
|
||||||
retryResp, errP := j.post(uri, reqBody)
|
|
||||||
if errP != nil {
|
|
||||||
return nil, fmt.Errorf("failed to post JWS message. -> %v", errP)
|
|
||||||
}
|
|
||||||
|
|
||||||
if retryResp.StatusCode >= http.StatusBadRequest {
|
|
||||||
return retryResp, handleHTTPError(retryResp)
|
|
||||||
}
|
|
||||||
|
|
||||||
if respBody == nil {
|
|
||||||
return retryResp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return retryResp, json.NewDecoder(retryResp.Body).Decode(respBody)
|
|
||||||
default:
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if respBody == nil {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp, 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)", UserAgent, ourUserAgent, ourUserAgentComment, runtime.GOOS, runtime.GOARCH)
|
|
||||||
return strings.TrimSpace(ua)
|
|
||||||
}
|
|
|
@ -1,42 +0,0 @@
|
||||||
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.Infof("[%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.Warnf("[%s] error cleaning up: %v", domain, err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return s.validate(s.jws, domain, chlng.URL, challenge{Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth})
|
|
||||||
}
|
|
|
@ -1,70 +0,0 @@
|
||||||
package acme
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"io/ioutil"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHTTPChallenge(t *testing.T) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
privKey, err := rsa.GenerateKey(rand.Reader, 512)
|
|
||||||
require.NoError(t, err, "Could not generate test key")
|
|
||||||
|
|
||||||
solver := &httpChallenge{
|
|
||||||
jws: &jws{privKey: privKey},
|
|
||||||
validate: mockValidate,
|
|
||||||
provider: &HTTPProviderServer{port: "23457"},
|
|
||||||
}
|
|
||||||
|
|
||||||
clientChallenge := challenge{Type: string(HTTP01), Token: "http1"}
|
|
||||||
|
|
||||||
err = solver.Solve(clientChallenge, "localhost:23457")
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHTTPChallengeInvalidPort(t *testing.T) {
|
|
||||||
privKey, err := rsa.GenerateKey(rand.Reader, 128)
|
|
||||||
require.NoError(t, err, "Could not generate test key")
|
|
||||||
|
|
||||||
solver := &httpChallenge{
|
|
||||||
jws: &jws{privKey: privKey},
|
|
||||||
validate: stubValidate,
|
|
||||||
provider: &HTTPProviderServer{port: "123456"},
|
|
||||||
}
|
|
||||||
|
|
||||||
clientChallenge := challenge{Type: string(HTTP01), Token: "http2"}
|
|
||||||
|
|
||||||
err = solver.Solve(clientChallenge, "localhost:123456")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "invalid port")
|
|
||||||
assert.Contains(t, err.Error(), "123456")
|
|
||||||
}
|
|
|
@ -1,179 +0,0 @@
|
||||||
package acme
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHTTPUserAgent(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()
|
|
||||||
|
|
||||||
testCases := []struct {
|
|
||||||
method string
|
|
||||||
call func(u string) (resp *http.Response, err error)
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
method: http.MethodGet,
|
|
||||||
call: httpGet,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: http.MethodHead,
|
|
||||||
call: httpHead,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: http.MethodPost,
|
|
||||||
call: func(u string) (resp *http.Response, err error) {
|
|
||||||
return httpPost(u, "text/plain", strings.NewReader("falalalala"))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range testCases {
|
|
||||||
t.Run(test.method, func(t *testing.T) {
|
|
||||||
|
|
||||||
_, err := test.call(ts.URL)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, test.method, method)
|
|
||||||
assert.Contains(t, ua, ourUserAgent, "User-Agent")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserAgent(t *testing.T) {
|
|
||||||
ua := userAgent()
|
|
||||||
|
|
||||||
assert.Contains(t, ua, ourUserAgent)
|
|
||||||
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()
|
|
||||||
|
|
||||||
assert.Contains(t, ua, ourUserAgent)
|
|
||||||
assert.Contains(t, ua, UserAgent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInitCertPool tests the http.go initCertPool function for customizing the
|
|
||||||
// HTTP Client *x509.CertPool with an environment variable.
|
|
||||||
func TestInitCertPool(t *testing.T) {
|
|
||||||
// writeTemp creates a temp file with the given contents & prefix and returns
|
|
||||||
// the file path. If an error occurs, t.Fatalf is called to end the test run.
|
|
||||||
writeTemp := func(t *testing.T, contents, prefix string) string {
|
|
||||||
t.Helper()
|
|
||||||
tmpFile, err := ioutil.TempFile("", prefix)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Unable to create tempfile: %v", err)
|
|
||||||
}
|
|
||||||
err = ioutil.WriteFile(tmpFile.Name(), []byte(contents), 0700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Unable to write tempfile contents: %v", err)
|
|
||||||
}
|
|
||||||
return tmpFile.Name()
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidFileContents := "not a certificate"
|
|
||||||
invalidFile := writeTemp(t, invalidFileContents, "invalid.pem")
|
|
||||||
|
|
||||||
// validFileContents is lifted from Pebble[0]. Generate your own CA cert with
|
|
||||||
// MiniCA[1].
|
|
||||||
// [0]: https://github.com/letsencrypt/pebble/blob/de6fa233ea1f283eeb9751d42c8e1ae72718c44e/test/certs/pebble.minica.pem
|
|
||||||
// [1]: https://github.com/jsha/minica
|
|
||||||
validFileContents := `
|
|
||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIDCTCCAfGgAwIBAgIIJOLbes8sTr4wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
|
|
||||||
AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMTcx
|
|
||||||
MjA2MTk0MjEwWjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAyNGUyZGIwggEi
|
|
||||||
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5WgZNoVJandj43kkLyU50vzCZ
|
|
||||||
alozvdRo3OFiKoDtmqKPNWRNO2hC9AUNxTDJco51Yc42u/WV3fPbbhSznTiOOVtn
|
|
||||||
Ajm6iq4I5nZYltGGZetGDOQWr78y2gWY+SG078MuOO2hyDIiKtVc3xiXYA+8Hluu
|
|
||||||
9F8KbqSS1h55yxZ9b87eKR+B0zu2ahzBCIHKmKWgc6N13l7aDxxY3D6uq8gtJRU0
|
|
||||||
toumyLbdzGcupVvjbjDP11nl07RESDWBLG1/g3ktJvqIa4BWgU2HMh4rND6y8OD3
|
|
||||||
Hy3H8MY6CElL+MOCbFJjWqhtOxeFyZZV9q3kYnk9CAuQJKMEGuN4GU6tzhW1AgMB
|
|
||||||
AAGjRTBDMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB
|
|
||||||
BQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkqhkiG9w0BAQsFAAOCAQEAF85v
|
|
||||||
d40HK1ouDAtWeO1PbnWfGEmC5Xa478s9ddOd9Clvp2McYzNlAFfM7kdcj6xeiNhF
|
|
||||||
WPIfaGAi/QdURSL/6C1KsVDqlFBlTs9zYfh2g0UXGvJtj1maeih7zxFLvet+fqll
|
|
||||||
xseM4P9EVJaQxwuK/F78YBt0tCNfivC6JNZMgxKF59h0FBpH70ytUSHXdz7FKwix
|
|
||||||
Mfn3qEb9BXSk0Q3prNV5sOV3vgjEtB4THfDxSz9z3+DepVnW3vbbqwEbkXdk3j82
|
|
||||||
2muVldgOUgTwK8eT+XdofVdntzU/kzygSAtAQwLJfn51fS1GvEcYGBc1bDryIqmF
|
|
||||||
p9BI7gVKtWSZYegicA==
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
`
|
|
||||||
validFile := writeTemp(t, validFileContents, "valid.pem")
|
|
||||||
|
|
||||||
testCases := []struct {
|
|
||||||
Name string
|
|
||||||
EnvVar string
|
|
||||||
ExpectPanic bool
|
|
||||||
ExpectNil bool
|
|
||||||
}{
|
|
||||||
// Setting the env var to a file that doesn't exist should panic
|
|
||||||
{
|
|
||||||
Name: "Env var with missing file",
|
|
||||||
EnvVar: "not.a.real.file.pem",
|
|
||||||
ExpectPanic: true,
|
|
||||||
},
|
|
||||||
// Setting the env var to a file that contains invalid content should panic
|
|
||||||
{
|
|
||||||
Name: "Env var with invalid content",
|
|
||||||
EnvVar: invalidFile,
|
|
||||||
ExpectPanic: true,
|
|
||||||
},
|
|
||||||
// Setting the env var to the empty string should not panic and should
|
|
||||||
// return nil
|
|
||||||
{
|
|
||||||
Name: "No env var",
|
|
||||||
EnvVar: "",
|
|
||||||
ExpectPanic: false,
|
|
||||||
ExpectNil: true,
|
|
||||||
},
|
|
||||||
// Setting the env var to a file that contains valid content should not
|
|
||||||
// panic and should not return nil
|
|
||||||
{
|
|
||||||
Name: "Env var with valid content",
|
|
||||||
EnvVar: validFile,
|
|
||||||
ExpectPanic: false,
|
|
||||||
ExpectNil: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range testCases {
|
|
||||||
t.Run(test.Name, func(t *testing.T) {
|
|
||||||
os.Setenv(caCertificatesEnvVar, test.EnvVar)
|
|
||||||
defer os.Setenv(caCertificatesEnvVar, "")
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
r := recover()
|
|
||||||
|
|
||||||
if test.ExpectPanic {
|
|
||||||
assert.NotNil(t, r, "expected initCertPool() to panic")
|
|
||||||
} else {
|
|
||||||
assert.Nil(t, r, "expected initCertPool() to not panic")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
result := initCertPool()
|
|
||||||
|
|
||||||
if test.ExpectNil {
|
|
||||||
assert.Nil(t, result)
|
|
||||||
} else {
|
|
||||||
assert.NotNil(t, result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
167
acme/jws.go
167
acme/jws.go
|
@ -1,167 +0,0 @@
|
||||||
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
103
acme/messages.go
|
@ -1,103 +0,0 @@
|
||||||
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,104 +0,0 @@
|
||||||
package acme
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rsa"
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509/pkix"
|
|
||||||
"encoding/asn1"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/xenolf/lego/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
// idPeAcmeIdentifierV1 is the SMI Security for PKIX Certification Extension OID referencing the ACME extension.
|
|
||||||
// Reference: https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05#section-5.1
|
|
||||||
var idPeAcmeIdentifierV1 = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 31}
|
|
||||||
|
|
||||||
type tlsALPNChallenge struct {
|
|
||||||
jws *jws
|
|
||||||
validate validateFunc
|
|
||||||
provider ChallengeProvider
|
|
||||||
}
|
|
||||||
|
|
||||||
// Solve manages the provider to validate and solve the challenge.
|
|
||||||
func (t *tlsALPNChallenge) Solve(chlng challenge, domain string) error {
|
|
||||||
log.Infof("[%s] acme: Trying to solve TLS-ALPN-01", domain)
|
|
||||||
|
|
||||||
// Generate the Key Authorization for the challenge
|
|
||||||
keyAuth, err := getKeyAuthorization(chlng.Token, t.jws.privKey)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = t.provider.Present(domain, chlng.Token, keyAuth)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("[%s] error presenting token: %v", domain, err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
err := t.provider.CleanUp(domain, chlng.Token, keyAuth)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("[%s] error cleaning up: %v", domain, err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return t.validate(t.jws, domain, chlng.URL, challenge{Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TLSALPNChallengeBlocks returns PEM blocks (certPEMBlock, keyPEMBlock) with the acmeValidation-v1 extension
|
|
||||||
// and domain name for the `tls-alpn-01` challenge.
|
|
||||||
func TLSALPNChallengeBlocks(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://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05#section-3
|
|
||||||
extensions := []pkix.Extension{
|
|
||||||
{
|
|
||||||
Id: idPeAcmeIdentifierV1,
|
|
||||||
Critical: true,
|
|
||||||
Value: value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a new RSA key for the certificates.
|
|
||||||
tempPrivKey, err := generatePrivateKey(RSA2048)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rsaPrivKey := tempPrivKey.(*rsa.PrivateKey)
|
|
||||||
|
|
||||||
// Generate the PEM certificate using the provided private key, domain, and extra extensions.
|
|
||||||
tempCertPEM, err := generatePemCert(rsaPrivKey, 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.
|
|
||||||
rsaPrivPEM := pemEncode(rsaPrivKey)
|
|
||||||
|
|
||||||
return tempCertPEM, rsaPrivPEM, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TLSALPNChallengeCert returns a certificate with the acmeValidation-v1 extension
|
|
||||||
// and domain name for the `tls-alpn-01` challenge.
|
|
||||||
func TLSALPNChallengeCert(domain, keyAuth string) (*tls.Certificate, error) {
|
|
||||||
tempCertPEM, rsaPrivPEM, err := TLSALPNChallengeBlocks(domain, keyAuth)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
certificate, err := tls.X509KeyPair(tempCertPEM, rsaPrivPEM)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &certificate, nil
|
|
||||||
}
|
|
252
certcrypto/crypto.go
Normal file
252
certcrypto/crypto.go
Normal file
|
@ -0,0 +1,252 @@
|
||||||
|
package certcrypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ocsp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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},
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
domains := []string{cert.Subject.CommonName}
|
||||||
|
|
||||||
|
// Check for SAN certificate
|
||||||
|
for _, sanDomain := range cert.DNSNames {
|
||||||
|
if sanDomain == cert.Subject.CommonName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
domains = append(domains, sanDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
return domains
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractDomainsCSR(csr *x509.CertificateRequest) []string {
|
||||||
|
domains := []string{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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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().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},
|
||||||
|
ExtraExtensions: extensions,
|
||||||
|
}
|
||||||
|
|
||||||
|
return x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
||||||
|
}
|
149
certcrypto/crypto_test.go
Normal file
149
certcrypto/crypto_test.go
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
package certcrypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"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)
|
||||||
|
assert.Len(t, data, 127)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockRandReader struct {
|
||||||
|
b *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r MockRandReader) Read(p []byte) (int, error) {
|
||||||
|
return r.b.Read(p)
|
||||||
|
}
|
69
certificate/authorization.go
Normal file
69
certificate/authorization.go
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
package certificate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/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) {
|
||||||
|
for _, auth := range order.Authorizations {
|
||||||
|
if err := c.core.Authorizations.Deactivate(auth); err != nil {
|
||||||
|
log.Infof("Unable to deactivated authorizations: %s", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
493
certificate/certificates.go
Normal file
493
certificate/certificates.go
Normal file
|
@ -0,0 +1,493 @@
|
||||||
|
package certificate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/acme/api"
|
||||||
|
"github.com/xenolf/lego/certcrypto"
|
||||||
|
"github.com/xenolf/lego/challenge"
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
"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.
|
||||||
|
type ObtainRequest struct {
|
||||||
|
Domains []string
|
||||||
|
Bundle bool
|
||||||
|
PrivateKey crypto.PrivateKey
|
||||||
|
MustStaple bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolver interface {
|
||||||
|
Solve(authorizations []acme.Authorization) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Certifier struct {
|
||||||
|
core *api.Core
|
||||||
|
keyType certcrypto.KeyType
|
||||||
|
resolver resolver
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCertifier(core *api.Core, keyType certcrypto.KeyType, resolver resolver) *Certifier {
|
||||||
|
return &Certifier{
|
||||||
|
core: core,
|
||||||
|
keyType: keyType,
|
||||||
|
resolver: resolver,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := c.core.Orders.New(domains)
|
||||||
|
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)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.resolver.Solve(authz)
|
||||||
|
if err != nil {
|
||||||
|
// If any challenge fails, return. Do not generate partial SAN certificates.
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
for _, auth := range authz {
|
||||||
|
failures[challenge.GetTargetedDomain(auth)] = 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(csr x509.CertificateRequest, bundle bool) (*Resource, error) {
|
||||||
|
// figure out what domains it concerns
|
||||||
|
// start with the common name
|
||||||
|
domains := certcrypto.ExtractDomainsCSR(&csr)
|
||||||
|
|
||||||
|
if 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, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := c.core.Orders.New(domains)
|
||||||
|
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)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.resolver.Solve(authz)
|
||||||
|
if err != nil {
|
||||||
|
// If any challenge fails, return. Do not generate partial SAN certificates.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("[%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", "))
|
||||||
|
|
||||||
|
failures := make(obtainError)
|
||||||
|
cert, err := c.getForCSR(domains, order, bundle, csr.Raw, nil)
|
||||||
|
if err != nil {
|
||||||
|
for _, auth := range authz {
|
||||||
|
failures[challenge.GetTargetedDomain(auth)] = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cert != nil {
|
||||||
|
// Add the CSR to the certificate so that it can be used for renewals.
|
||||||
|
cert.CSR = certcrypto.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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bundle bool, privateKey crypto.PrivateKey, mustStaple bool) (*Resource, error) {
|
||||||
|
if privateKey == nil {
|
||||||
|
var err error
|
||||||
|
privateKey, err = certcrypto.GeneratePrivateKey(c.keyType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine certificate name(s) based on the authorization resources
|
||||||
|
commonName := domains[0]
|
||||||
|
|
||||||
|
// ACME draft Section 7.4 "Applying for Certificate Issuance"
|
||||||
|
// https://tools.ietf.org/html/draft-ietf-acme-acme-12#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))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Certifier) getForCSR(domains []string, order acme.ExtendedOrder, bundle bool, csr []byte, privateKeyPem []byte) (*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, err := c.checkResponse(respOrder, certRes, bundle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
return certRes, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.waitForCertificate(certRes, order.Location, bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Certifier) waitForCertificate(certRes *Resource, orderURL string, bundle bool) (*Resource, error) {
|
||||||
|
stopTimer := time.NewTimer(30 * time.Second)
|
||||||
|
defer stopTimer.Stop()
|
||||||
|
retryTick := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer retryTick.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stopTimer.C:
|
||||||
|
return nil, errors.New("certificate polling timed out")
|
||||||
|
case <-retryTick.C:
|
||||||
|
order, err := c.core.Orders.Get(orderURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
done, err := c.checkResponse(order, certRes, bundle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if done {
|
||||||
|
return certRes, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.Order, certRes *Resource, bundle bool) (bool, error) {
|
||||||
|
valid, err := checkOrderStatus(order)
|
||||||
|
if err != nil || !valid {
|
||||||
|
return valid, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, issuer, err := c.core.Certificates.Get(order.Certificate, bundle)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("[%s] Server responded with a certificate.", certRes.Domain)
|
||||||
|
|
||||||
|
certRes.IssuerCertificate = issuer
|
||||||
|
certRes.Certificate = cert
|
||||||
|
certRes.CertURL = order.Certificate
|
||||||
|
certRes.CertStableURL = order.Certificate
|
||||||
|
|
||||||
|
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 {
|
||||||
|
certificates, err := certcrypto.ParsePEMBundle(cert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
x509Cert := certificates[0]
|
||||||
|
if x509Cert.IsCA {
|
||||||
|
return fmt.Errorf("certificate bundle starts with a CA certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeMsg := acme.RevokeCertMessage{
|
||||||
|
Certificate: base64.RawURLEncoding.EncodeToString(x509Cert.Raw),
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.core.Certificates.Revoke(revokeMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renew takes a Resource 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 Resource should be non-nil.
|
||||||
|
func (c *Certifier) Renew(certRes Resource, bundle, mustStaple bool) (*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
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.ObtainForCSR(*csr, bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
var privateKey crypto.PrivateKey
|
||||||
|
if certRes.PrivateKey != nil {
|
||||||
|
privateKey, err = certcrypto.ParsePEMPrivateKey(certRes.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := ObtainRequest{
|
||||||
|
Domains: certcrypto.ExtractDomains(x509Cert),
|
||||||
|
Bundle: bundle,
|
||||||
|
PrivateKey: privateKey,
|
||||||
|
MustStaple: mustStaple,
|
||||||
|
}
|
||||||
|
return c.Obtain(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := ioutil.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 := ioutil.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
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkOrderStatus(order acme.Order) (bool, error) {
|
||||||
|
switch order.Status {
|
||||||
|
case acme.StatusValid:
|
||||||
|
return true, nil
|
||||||
|
case acme.StatusInvalid:
|
||||||
|
return false, order.Error
|
||||||
|
default:
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://tools.ietf.org/html/draft-ietf-acme-acme-16#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://tools.ietf.org/html/rfc5280#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
|
||||||
|
}
|
211
certificate/certificates_test.go
Normal file
211
certificate/certificates_test.go
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
package certificate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/pem"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/acme/api"
|
||||||
|
"github.com/xenolf/lego/certcrypto"
|
||||||
|
"github.com/xenolf/lego/platform/tester"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 Test_checkResponse(t *testing.T) {
|
||||||
|
mux, apiURL, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
mux.HandleFunc("/certificate", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, err := w.Write([]byte(certResponseMock))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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, certcrypto.RSA2048, &resolverMock{})
|
||||||
|
|
||||||
|
order := acme.Order{
|
||||||
|
Status: acme.StatusValid,
|
||||||
|
Certificate: apiURL + "/certificate",
|
||||||
|
}
|
||||||
|
certRes := &Resource{}
|
||||||
|
bundle := false
|
||||||
|
|
||||||
|
valid, err := certifier.checkResponse(order, certRes, bundle)
|
||||||
|
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, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
mux.HandleFunc("/certificate", func(w http.ResponseWriter, r *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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/issuer", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, _ := pem.Decode([]byte(issuerMock))
|
||||||
|
_, err := w.Write(p.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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, certcrypto.RSA2048, &resolverMock{})
|
||||||
|
|
||||||
|
order := acme.Order{
|
||||||
|
Status: acme.StatusValid,
|
||||||
|
Certificate: apiURL + "/certificate",
|
||||||
|
}
|
||||||
|
certRes := &Resource{}
|
||||||
|
bundle := false
|
||||||
|
|
||||||
|
valid, err := certifier.checkResponse(order, certRes, bundle)
|
||||||
|
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_embeddedIssuer(t *testing.T) {
|
||||||
|
mux, apiURL, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
mux.HandleFunc("/certificate", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, err := w.Write([]byte(certResponseMock))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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, certcrypto.RSA2048, &resolverMock{})
|
||||||
|
|
||||||
|
order := acme.Order{
|
||||||
|
Status: acme.StatusValid,
|
||||||
|
Certificate: apiURL + "/certificate",
|
||||||
|
}
|
||||||
|
certRes := &Resource{}
|
||||||
|
bundle := false
|
||||||
|
|
||||||
|
valid, err := certifier.checkResponse(order, certRes, bundle)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolverMock struct {
|
||||||
|
error error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *resolverMock) Solve(authorizations []acme.Authorization) error {
|
||||||
|
return r.error
|
||||||
|
}
|
30
certificate/errors.go
Normal file
30
certificate/errors.go
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
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("acme: 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 {
|
||||||
|
buffer.WriteString(fmt.Sprintf("[%s] %s\n", domain, e[domain]))
|
||||||
|
}
|
||||||
|
return buffer.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
type domainError struct {
|
||||||
|
Domain string
|
||||||
|
Error error
|
||||||
|
}
|
44
challenge/challenges.go
Normal file
44
challenge/challenges.go
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
package challenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/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://tools.ietf.org/html/draft-ietf-acme-acme-16#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://tools.ietf.org/html/draft-ietf-acme-acme-16#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://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05
|
||||||
|
TLSALPN01 = Type("tls-alpn-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
|
||||||
|
}
|
174
challenge/dns01/dns_challenge.go
Normal file
174
challenge/dns01/dns_challenge.go
Normal file
|
@ -0,0 +1,174 @@
|
||||||
|
package dns01
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/acme/api"
|
||||||
|
"github.com/xenolf/lego/challenge"
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
"github.com/xenolf/lego/platform/wait"
|
||||||
|
)
|
||||||
|
|
||||||
|
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: %s", 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
|
||||||
|
}
|
||||||
|
|
||||||
|
fqdn, value := GetRecord(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)
|
||||||
|
|
||||||
|
err = wait.For(timeout, interval, func() (bool, error) {
|
||||||
|
stop, errP := c.preCheck.call(fqdn, 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, authz.Identifier.Value, chlng)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanUp cleans the challenge.
|
||||||
|
func (c *Challenge) CleanUp(authz acme.Authorization) error {
|
||||||
|
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
|
||||||
|
func GetRecord(domain, keyAuth string) (fqdn string, value string) {
|
||||||
|
keyAuthShaBytes := sha256.Sum256([]byte(keyAuth))
|
||||||
|
// base64URL encoding without padding
|
||||||
|
value = base64.RawURLEncoding.EncodeToString(keyAuthShaBytes[:sha256.Size])
|
||||||
|
fqdn = fmt.Sprintf("_acme-challenge.%s.", domain)
|
||||||
|
return
|
||||||
|
}
|
52
challenge/dns01/dns_challenge_manual.go
Normal file
52
challenge/dns01/dns_challenge_manual.go
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
package dns01
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dnsTemplate = `%s %d IN TXT "%s"`
|
||||||
|
)
|
||||||
|
|
||||||
|
// DNSProviderManual is an implementation of the ChallengeProvider interface
|
||||||
|
type DNSProviderManual struct{}
|
||||||
|
|
||||||
|
// NewDNSProviderManual returns a DNSProviderManual instance.
|
||||||
|
func NewDNSProviderManual() (*DNSProviderManual, error) {
|
||||||
|
return &DNSProviderManual{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Present prints instructions for manually creating the TXT record
|
||||||
|
func (*DNSProviderManual) Present(domain, token, keyAuth string) error {
|
||||||
|
fqdn, value := GetRecord(domain, keyAuth)
|
||||||
|
|
||||||
|
authZone, err := FindZoneByFqdn(fqdn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("lego: Please create the following TXT record in your %s zone:\n", authZone)
|
||||||
|
fmt.Printf(dnsTemplate+"\n", fqdn, DefaultTTL, 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 {
|
||||||
|
fqdn, _ := GetRecord(domain, keyAuth)
|
||||||
|
|
||||||
|
authZone, err := FindZoneByFqdn(fqdn)
|
||||||
|
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", fqdn, DefaultTTL, "...")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
61
challenge/dns01/dns_challenge_manual_test.go
Normal file
61
challenge/dns01/dns_challenge_manual_test.go
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
package dns01
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"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 := ioutil.TempFile("", "lego_test")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer func() { _ = os.Remove(file.Name()) }()
|
||||||
|
|
||||||
|
_, err = io.WriteString(file, 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
285
challenge/dns01/dns_challenge_test.go
Normal file
285
challenge/dns01/dns_challenge_test.go
Normal file
|
@ -0,0 +1,285 @@
|
||||||
|
package dns01
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/acme/api"
|
||||||
|
"github.com/xenolf/lego/challenge"
|
||||||
|
"github.com/xenolf/lego/platform/tester"
|
||||||
|
)
|
||||||
|
|
||||||
|
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, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
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 PreCheckFunc
|
||||||
|
provider challenge.Provider
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "success",
|
||||||
|
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
|
||||||
|
preCheck: func(_, _ string) (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) (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) (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) (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) (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, AddPreCheck(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, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
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 PreCheckFunc
|
||||||
|
provider challenge.Provider
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "success",
|
||||||
|
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
|
||||||
|
preCheck: func(_, _ string) (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) (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) (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) (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) (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, AddPreCheck(test.preCheck))
|
||||||
|
|
||||||
|
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, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
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 PreCheckFunc
|
||||||
|
provider challenge.Provider
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "success",
|
||||||
|
validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
|
||||||
|
preCheck: func(_, _ string) (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) (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) (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) (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) (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, AddPreCheck(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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
19
challenge/dns01/fqdn.go
Normal file
19
challenge/dns01/fqdn.go
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
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
|
||||||
|
}
|
66
challenge/dns01/fqdn_test.go
Normal file
66
challenge/dns01/fqdn_test.go
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
232
challenge/dns01/nameserver.go
Normal file
232
challenge/dns01/nameserver.go
Normal file
|
@ -0,0 +1,232 @@
|
||||||
|
package dns01
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultResolvConf = "/etc/resolv.conf"
|
||||||
|
|
||||||
|
// dnsTimeout is used to override the default DNS timeout of 10 seconds.
|
||||||
|
var dnsTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
var (
|
||||||
|
fqdnToZone = map[string]string{}
|
||||||
|
muFqdnToZone 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)
|
||||||
|
|
||||||
|
// ClearFqdnCache clears the cache of fqdn to zone mappings. Primarily used in testing.
|
||||||
|
func ClearFqdnCache() {
|
||||||
|
muFqdnToZone.Lock()
|
||||||
|
fqdnToZone = map[string]string{}
|
||||||
|
muFqdnToZone.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: %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) (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) {
|
||||||
|
muFqdnToZone.Lock()
|
||||||
|
defer muFqdnToZone.Unlock()
|
||||||
|
|
||||||
|
// Do we have it cached?
|
||||||
|
if zone, ok := fqdnToZone[fqdn]; ok {
|
||||||
|
return zone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
zone := soa.Hdr.Name
|
||||||
|
fqdnToZone[fqdn] = zone
|
||||||
|
return zone, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case dns.RcodeNameError:
|
||||||
|
// NXDOMAIN
|
||||||
|
default:
|
||||||
|
// Any response code other than NOERROR and NXDOMAIN is treated as error
|
||||||
|
return "", fmt.Errorf("unexpected response code '%s' for %s", dns.RcodeToString[in.Rcode], domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", 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) {
|
||||||
|
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, fmt.Sprintf("%v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) > 0 {
|
||||||
|
return ": " + strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
177
challenge/dns01/nameserver_test.go
Normal file
177
challenge/dns01/nameserver_test.go
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
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: "books.google.com.ng.",
|
||||||
|
nss: []string{"ns1.google.com.", "ns2.google.com.", "ns3.google.com.", "ns4.google.com."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindZoneByFqdnCustom(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
desc string
|
||||||
|
fqdn string
|
||||||
|
zone string
|
||||||
|
nameservers []string
|
||||||
|
expectedError string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "domain is a CNAME",
|
||||||
|
fqdn: "mail.google.com.",
|
||||||
|
zone: "google.com.",
|
||||||
|
nameservers: recursiveNameservers,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "domain is a non-existent subdomain",
|
||||||
|
fqdn: "foo.google.com.",
|
||||||
|
zone: "google.com.",
|
||||||
|
nameservers: recursiveNameservers,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "domain is a eTLD",
|
||||||
|
fqdn: "example.com.ac.",
|
||||||
|
zone: "ac.",
|
||||||
|
nameservers: recursiveNameservers,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "domain is a cross-zone CNAME",
|
||||||
|
fqdn: "cross-zone-example.assets.sh.",
|
||||||
|
zone: "assets.sh.",
|
||||||
|
nameservers: recursiveNameservers,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "NXDOMAIN",
|
||||||
|
fqdn: "test.loho.jkl.",
|
||||||
|
zone: "loho.jkl.",
|
||||||
|
nameservers: []string{"1.1.1.1:53"},
|
||||||
|
expectedError: "could not find the start of authority for test.loho.jkl.: NXDOMAIN",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "several non existent nameservers",
|
||||||
|
fqdn: "mail.google.com.",
|
||||||
|
zone: "google.com.",
|
||||||
|
nameservers: []string{":7053", ":8053", "1.1.1.1: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.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range testCases {
|
||||||
|
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 TestResolveConfServers(t *testing.T) {
|
||||||
|
var 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
110
challenge/dns01/precheck.go
Normal file
110
challenge/dns01/precheck.go
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
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)
|
||||||
|
|
||||||
|
func AddPreCheck(preCheck PreCheckFunc) ChallengeOption {
|
||||||
|
// Prevent race condition
|
||||||
|
check := preCheck
|
||||||
|
return func(chlg *Challenge) error {
|
||||||
|
chlg.preCheck.checkFunc = check
|
||||||
|
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 PreCheckFunc
|
||||||
|
// 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(fqdn, value string) (bool, error) {
|
||||||
|
if p.checkFunc == nil {
|
||||||
|
return p.checkDNSPropagation(fqdn, value)
|
||||||
|
}
|
||||||
|
return p.checkFunc(fqdn, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// 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 [fqdn: %s]", ns, fqdn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
117
challenge/dns01/precheck_test.go
Normal file
117
challenge/dns01/precheck_test.go
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
65
challenge/http01/http_challenge.go
Normal file
65
challenge/http01/http_challenge.go
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
package http01
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/acme/api"
|
||||||
|
"github.com/xenolf/lego/challenge"
|
||||||
|
"github.com/xenolf/lego/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: %v", domain, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
err := c.provider.CleanUp(authz.Identifier.Value, chlng.Token, keyAuth)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("[%s] acme: error cleaning up: %v", domain, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
chlng.KeyAuthorization = keyAuth
|
||||||
|
return c.validate(c.core, authz.Identifier.Value, chlng)
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package acme
|
package http01
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -9,31 +9,31 @@ import (
|
||||||
"github.com/xenolf/lego/log"
|
"github.com/xenolf/lego/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTPProviderServer implements ChallengeProvider for `http-01` challenge
|
// ProviderServer implements ChallengeProvider for `http-01` challenge
|
||||||
// It may be instantiated without using the NewHTTPProviderServer function if
|
// It may be instantiated without using the NewProviderServer function if
|
||||||
// you want only to use the default values.
|
// you want only to use the default values.
|
||||||
type HTTPProviderServer struct {
|
type ProviderServer struct {
|
||||||
iface string
|
iface string
|
||||||
port string
|
port string
|
||||||
done chan bool
|
done chan bool
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTTPProviderServer creates a new HTTPProviderServer on the selected interface and port.
|
// 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
|
// Setting iface and / or port to an empty string will make the server fall back to
|
||||||
// the "any" interface and port 80 respectively.
|
// the "any" interface and port 80 respectively.
|
||||||
func NewHTTPProviderServer(iface, port string) *HTTPProviderServer {
|
func NewProviderServer(iface, port string) *ProviderServer {
|
||||||
return &HTTPProviderServer{iface: iface, port: port}
|
return &ProviderServer{iface: iface, port: port}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Present starts a web server and makes the token available at `HTTP01ChallengePath(token)` for web requests.
|
// Present starts a web server and makes the token available at `ChallengePath(token)` for web requests.
|
||||||
func (s *HTTPProviderServer) Present(domain, token, keyAuth string) error {
|
func (s *ProviderServer) Present(domain, token, keyAuth string) error {
|
||||||
if s.port == "" {
|
if s.port == "" {
|
||||||
s.port = "80"
|
s.port = "80"
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
s.listener, err = net.Listen("tcp", net.JoinHostPort(s.iface, s.port))
|
s.listener, err = net.Listen("tcp", s.GetAddress())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not start HTTP server for challenge -> %v", err)
|
return fmt.Errorf("could not start HTTP server for challenge -> %v", err)
|
||||||
}
|
}
|
||||||
|
@ -43,8 +43,12 @@ func (s *HTTPProviderServer) Present(domain, token, keyAuth string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanUp closes the HTTP server and removes the token from `HTTP01ChallengePath(token)`
|
func (s *ProviderServer) GetAddress() string {
|
||||||
func (s *HTTPProviderServer) CleanUp(domain, token, keyAuth string) error {
|
return net.JoinHostPort(s.iface, s.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if s.listener == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -53,8 +57,8 @@ func (s *HTTPProviderServer) CleanUp(domain, token, keyAuth string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HTTPProviderServer) serve(domain, token, keyAuth string) {
|
func (s *ProviderServer) serve(domain, token, keyAuth string) {
|
||||||
path := HTTP01ChallengePath(token)
|
path := ChallengePath(token)
|
||||||
|
|
||||||
// The handler validates the HOST header and request type.
|
// The handler validates the HOST header and request type.
|
||||||
// For validation it then writes the token the server returned with the challenge
|
// For validation it then writes the token the server returned with the challenge
|
||||||
|
@ -80,12 +84,12 @@ func (s *HTTPProviderServer) serve(domain, token, keyAuth string) {
|
||||||
|
|
||||||
httpServer := &http.Server{Handler: mux}
|
httpServer := &http.Server{Handler: mux}
|
||||||
|
|
||||||
// Once httpServer is shut down we don't want any lingering
|
// Once httpServer is shut down
|
||||||
// connections, so disable KeepAlives.
|
// we don't want any lingering connections, so disable KeepAlives.
|
||||||
httpServer.SetKeepAlivesEnabled(false)
|
httpServer.SetKeepAlivesEnabled(false)
|
||||||
|
|
||||||
err := httpServer.Serve(s.listener)
|
err := httpServer.Serve(s.listener)
|
||||||
if err != nil {
|
if err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
}
|
}
|
||||||
s.done <- true
|
s.done <- true
|
98
challenge/http01/http_challenge_test.go
Normal file
98
challenge/http01/http_challenge_test.go
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
package http01
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/acme/api"
|
||||||
|
"github.com/xenolf/lego/challenge"
|
||||||
|
"github.com/xenolf/lego/platform/tester"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChallenge(t *testing.T) {
|
||||||
|
_, apiURL, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
providerServer := &ProviderServer{port: "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 := 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 TestChallengeInvalidPort(t *testing.T) {
|
||||||
|
_, apiURL, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
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, &ProviderServer{port: "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")
|
||||||
|
}
|
|
@ -1,28 +1,28 @@
|
||||||
package acme
|
package challenge
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// ChallengeProvider enables implementing a custom challenge
|
// Provider 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 ChallengeProvider interface {
|
type Provider 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChallengeProviderTimeout allows for implementing a
|
// ProviderTimeout allows for implementing a
|
||||||
// ChallengeProvider where an unusually long timeout is required when
|
// Provider 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 progagation. If an implementor of a
|
// checking for DNS record propagation. If an implementor of a
|
||||||
// ChallengeProvider provides a Timeout method, then the return values
|
// Provider 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 ChallengeProvider.
|
// defined for the Provider.
|
||||||
type ChallengeProviderTimeout interface {
|
type ProviderTimeout interface {
|
||||||
ChallengeProvider
|
Provider
|
||||||
Timeout() (timeout, interval time.Duration)
|
Timeout() (timeout, interval time.Duration)
|
||||||
}
|
}
|
25
challenge/resolver/errors.go
Normal file
25
challenge/resolver/errors.go
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
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("acme: 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 {
|
||||||
|
buffer.WriteString(fmt.Sprintf("[%s] %s\n", domain, e[domain]))
|
||||||
|
}
|
||||||
|
return buffer.String()
|
||||||
|
}
|
172
challenge/resolver/prober.go
Normal file
172
challenge/resolver/prober.go
Normal file
|
@ -0,0 +1,172 @@
|
||||||
|
package resolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/challenge"
|
||||||
|
"github.com/xenolf/lego/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[authSolver.authz.Identifier.Value] = 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
|
||||||
|
if failures[authz.Identifier.Value] != nil {
|
||||||
|
// already failed in previous loop
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err := authSolver.solver.Solve(authz)
|
||||||
|
if err != nil {
|
||||||
|
failures[authz.Identifier.Value] = 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: error cleaning up: %v ", domain, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
42
challenge/resolver/prober_mock_test.go
Normal file
42
challenge/resolver/prober_mock_test.go
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
package resolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
118
challenge/resolver/prober_test.go
Normal file
118
challenge/resolver/prober_test.go
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
package resolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/challenge"
|
||||||
|
)
|
||||||
|
|
||||||
|
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: `acme: 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: `acme: 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
201
challenge/resolver/solver_manager.go
Normal file
201
challenge/resolver/solver_manager.go
Normal file
|
@ -0,0 +1,201 @@
|
||||||
|
package resolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/acme/api"
|
||||||
|
"github.com/xenolf/lego/challenge"
|
||||||
|
"github.com/xenolf/lego/challenge/dns01"
|
||||||
|
"github.com/xenolf/lego/challenge/http01"
|
||||||
|
"github.com/xenolf/lego/challenge/tlsalpn01"
|
||||||
|
"github.com/xenolf/lego/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 {
|
||||||
|
solvers := map[challenge.Type]solver{
|
||||||
|
challenge.HTTP01: http01.NewChallenge(core, validate, &http01.ProviderServer{}),
|
||||||
|
challenge.TLSALPN01: tlsalpn01.NewChallenge(core, validate, &tlsalpn01.ProviderServer{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SolverManager{
|
||||||
|
solvers: solvers,
|
||||||
|
core: core,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHTTP01Address 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.SetProvider with the default HTTP challenge provider.
|
||||||
|
func (c *SolverManager) SetHTTP01Address(iface string) error {
|
||||||
|
host, port, err := net.SplitHostPort(iface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if chlng, ok := c.solvers[challenge.HTTP01]; ok {
|
||||||
|
chlng.(*http01.Challenge).SetProvider(http01.NewProviderServer(host, port))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTLSALPN01Address specifies a custom interface:port to be used for TLS based challenges.
|
||||||
|
// If this option is not used, the default port 443 and all interfaces will be used.
|
||||||
|
// To only specify a port and no interface use the ":port" notation.
|
||||||
|
//
|
||||||
|
// NOTE: This REPLACES any custom TLS-ALPN provider previously set by calling
|
||||||
|
// c.SetProvider with the default TLS-ALPN challenge provider.
|
||||||
|
func (c *SolverManager) SetTLSALPN01Address(iface string) error {
|
||||||
|
host, port, err := net.SplitHostPort(iface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if chlng, ok := c.solvers[challenge.TLSALPN01]; ok {
|
||||||
|
chlng.(*tlsalpn01.Challenge).SetProvider(tlsalpn01.NewProviderServer(host, port))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude explicitly removes challenges from the pool for solving.
|
||||||
|
func (c *SolverManager) Exclude(challenges []challenge.Type) {
|
||||||
|
// Loop through all challenges and delete the requested one if found.
|
||||||
|
for _, chlg := range challenges {
|
||||||
|
delete(c.solvers, chlg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(sort.Reverse(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: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := checkChallengeStatus(chlng)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if valid {
|
||||||
|
log.Infof("[%s] The server validated our request", domain)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the path is sent, the ACME server will access our server.
|
||||||
|
// Repeatedly check the server for an updated status on our request.
|
||||||
|
for {
|
||||||
|
authz, err := core.Authorizations.Get(chlng.AuthorizationURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := checkAuthorizationStatus(authz)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(ra) * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
203
challenge/resolver/solver_manager_test.go
Normal file
203
challenge/resolver/solver_manager_test.go
Normal file
|
@ -0,0 +1,203 @@
|
||||||
|
package resolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/acme/api"
|
||||||
|
"github.com/xenolf/lego/challenge"
|
||||||
|
"github.com/xenolf/lego/challenge/http01"
|
||||||
|
"github.com/xenolf/lego/platform/tester"
|
||||||
|
"gopkg.in/square/go-jose.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSolverManager_SetHTTP01Address(t *testing.T) {
|
||||||
|
_, apiURL, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
keyBits := 32 // small value keeps test fast
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, keyBits)
|
||||||
|
require.NoError(t, err, "Could not generate test key")
|
||||||
|
|
||||||
|
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
solversManager := NewSolversManager(core)
|
||||||
|
|
||||||
|
optPort := "1234"
|
||||||
|
optHost := ""
|
||||||
|
|
||||||
|
err = solversManager.SetHTTP01Address(net.JoinHostPort(optHost, optPort))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.IsType(t, &http01.Challenge{}, solversManager.solvers[challenge.HTTP01])
|
||||||
|
httpSolver := solversManager.solvers[challenge.HTTP01].(*http01.Challenge)
|
||||||
|
|
||||||
|
httpProviderServer := (*http01.ProviderServer)(unsafe.Pointer(reflect.ValueOf(httpSolver).Elem().FieldByName("provider").InterfaceData()[1]))
|
||||||
|
assert.Equal(t, net.JoinHostPort(optHost, optPort), httpProviderServer.GetAddress())
|
||||||
|
|
||||||
|
// test setting different host
|
||||||
|
optHost = "127.0.0.1"
|
||||||
|
err = solversManager.SetHTTP01Address(net.JoinHostPort(optHost, optPort))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
httpProviderServer = (*http01.ProviderServer)(unsafe.Pointer(reflect.ValueOf(httpSolver).Elem().FieldByName("provider").InterfaceData()[1]))
|
||||||
|
assert.Equal(t, net.JoinHostPort(optHost, optPort), httpProviderServer.GetAddress())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate(t *testing.T) {
|
||||||
|
mux, apiURL, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
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 := ioutil.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
|
||||||
|
}
|
129
challenge/tlsalpn01/tls_alpn_challenge.go
Normal file
129
challenge/tlsalpn01/tls_alpn_challenge.go
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
package tlsalpn01
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/acme/api"
|
||||||
|
"github.com/xenolf/lego/certcrypto"
|
||||||
|
"github.com/xenolf/lego/challenge"
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// idPeAcmeIdentifierV1 is the SMI Security for PKIX Certification Extension OID referencing the ACME extension.
|
||||||
|
// Reference: https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05#section-5.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: %v", challenge.GetTargetedDomain(authz), err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
err := c.provider.CleanUp(domain, chlng.Token, keyAuth)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("[%s] acme: error cleaning up: %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://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05#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
|
||||||
|
}
|
|
@ -1,49 +1,54 @@
|
||||||
package acme
|
package tlsalpn01
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// ACMETLS1Protocol is the ALPN Protocol ID for the ACME-TLS/1 Protocol.
|
// ACMETLS1Protocol is the ALPN Protocol ID for the ACME-TLS/1 Protocol.
|
||||||
ACMETLS1Protocol = "acme-tls/1"
|
ACMETLS1Protocol = "acme-tls/1"
|
||||||
|
|
||||||
// defaultTLSPort is the port that the TLSALPNProviderServer will default to
|
// defaultTLSPort is the port that the ProviderServer will default to
|
||||||
// when no other port is provided.
|
// when no other port is provided.
|
||||||
defaultTLSPort = "443"
|
defaultTLSPort = "443"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TLSALPNProviderServer implements ChallengeProvider for `TLS-ALPN-01`
|
// ProviderServer implements ChallengeProvider for `TLS-ALPN-01` challenge.
|
||||||
// challenge. It may be instantiated without using the NewTLSALPNProviderServer
|
// It may be instantiated without using the NewProviderServer
|
||||||
// if you want only to use the default values.
|
// if you want only to use the default values.
|
||||||
type TLSALPNProviderServer struct {
|
type ProviderServer struct {
|
||||||
iface string
|
iface string
|
||||||
port string
|
port string
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTLSALPNProviderServer creates a new TLSALPNProviderServer on the selected
|
// NewProviderServer creates a new ProviderServer on the selected interface and port.
|
||||||
// interface and port. Setting iface and / or port to an empty string will make
|
// Setting iface and / or port to an empty string will make the server fall back to
|
||||||
// the server fall back to the "any" interface and port 443 respectively.
|
// the "any" interface and port 443 respectively.
|
||||||
func NewTLSALPNProviderServer(iface, port string) *TLSALPNProviderServer {
|
func NewProviderServer(iface, port string) *ProviderServer {
|
||||||
return &TLSALPNProviderServer{iface: iface, port: port}
|
return &ProviderServer{iface: iface, port: port}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProviderServer) GetAddress() string {
|
||||||
|
return net.JoinHostPort(s.iface, s.port)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Present generates a certificate with a SHA-256 digest of the keyAuth provided
|
// Present generates a certificate with a SHA-256 digest of the keyAuth provided
|
||||||
// as the acmeValidation-v1 extension value to conform to the ACME-TLS-ALPN
|
// as the acmeValidation-v1 extension value to conform to the ACME-TLS-ALPN spec.
|
||||||
// spec.
|
func (s *ProviderServer) Present(domain, token, keyAuth string) error {
|
||||||
func (t *TLSALPNProviderServer) Present(domain, token, keyAuth string) error {
|
if s.port == "" {
|
||||||
if t.port == "" {
|
|
||||||
// Fallback to port 443 if the port was not provided.
|
// Fallback to port 443 if the port was not provided.
|
||||||
t.port = defaultTLSPort
|
s.port = defaultTLSPort
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate the challenge certificate using the provided keyAuth and domain.
|
// Generate the challenge certificate using the provided keyAuth and domain.
|
||||||
cert, err := TLSALPNChallengeCert(domain, keyAuth)
|
cert, err := ChallengeCert(domain, keyAuth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -59,15 +64,15 @@ func (t *TLSALPNProviderServer) Present(domain, token, keyAuth string) error {
|
||||||
tlsConf.NextProtos = []string{ACMETLS1Protocol}
|
tlsConf.NextProtos = []string{ACMETLS1Protocol}
|
||||||
|
|
||||||
// Create the listener with the created tls.Config.
|
// Create the listener with the created tls.Config.
|
||||||
t.listener, err = tls.Listen("tcp", net.JoinHostPort(t.iface, t.port), tlsConf)
|
s.listener, err = tls.Listen("tcp", s.GetAddress(), tlsConf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not start HTTPS server for challenge -> %v", err)
|
return fmt.Errorf("could not start HTTPS server for challenge -> %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shut the server down when we're finished.
|
// Shut the server down when we're finished.
|
||||||
go func() {
|
go func() {
|
||||||
err := http.Serve(t.listener, nil)
|
err := http.Serve(s.listener, nil)
|
||||||
if err != nil {
|
if err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
@ -76,13 +81,13 @@ func (t *TLSALPNProviderServer) Present(domain, token, keyAuth string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanUp closes the HTTPS server.
|
// CleanUp closes the HTTPS server.
|
||||||
func (t *TLSALPNProviderServer) CleanUp(domain, token, keyAuth string) error {
|
func (s *ProviderServer) CleanUp(domain, token, keyAuth string) error {
|
||||||
if t.listener == nil {
|
if s.listener == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server was created, close it.
|
// Server was created, close it.
|
||||||
if err := t.listener.Close(); err != nil && err != http.ErrServerClosed {
|
if err := s.listener.Close(); err != nil && err != http.ErrServerClosed {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
package acme
|
package tlsalpn01
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
@ -7,16 +7,24 @@ import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/asn1"
|
"encoding/asn1"
|
||||||
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/xenolf/lego/acme"
|
||||||
|
"github.com/xenolf/lego/acme/api"
|
||||||
|
"github.com/xenolf/lego/challenge"
|
||||||
|
"github.com/xenolf/lego/platform/tester"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTLSALPNChallenge(t *testing.T) {
|
func TestChallenge(t *testing.T) {
|
||||||
|
_, apiURL, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
domain := "localhost:23457"
|
domain := "localhost:23457"
|
||||||
|
|
||||||
mockValidate := func(_ *jws, _, _ string, chlng challenge) error {
|
mockValidate := func(_ *api.Core, _ string, chlng acme.Challenge) error {
|
||||||
conn, err := tls.Dial("tcp", domain, &tls.Config{
|
conn, err := tls.Dial("tcp", domain, &tls.Config{
|
||||||
InsecureSkipVerify: true,
|
InsecureSkipVerify: true,
|
||||||
})
|
})
|
||||||
|
@ -48,41 +56,64 @@ func TestTLSALPNChallenge(t *testing.T) {
|
||||||
value, err := asn1.Marshal(zBytes[:sha256.Size])
|
value, err := asn1.Marshal(zBytes[:sha256.Size])
|
||||||
require.NoError(t, err, "Expected marshaling of the keyAuth to return no error")
|
require.NoError(t, err, "Expected marshaling of the keyAuth to return no error")
|
||||||
|
|
||||||
if subtle.ConstantTimeCompare(value[:], ext.Value) != 1 {
|
if subtle.ConstantTimeCompare(value, ext.Value) != 1 {
|
||||||
t.Errorf("Expected the challenge certificate id-pe-acmeIdentifier extension to contain the SHA-256 digest of the keyAuth, %v, but was %v", zBytes[:], ext.Value)
|
t.Errorf("Expected the challenge certificate id-pe-acmeIdentifier extension to contain the SHA-256 digest of the keyAuth, %v, but was %v", zBytes[:], ext.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
privKey, err := rsa.GenerateKey(rand.Reader, 512)
|
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
|
||||||
require.NoError(t, err, "Could not generate test key")
|
require.NoError(t, err, "Could not generate test key")
|
||||||
|
|
||||||
solver := &tlsALPNChallenge{
|
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
|
||||||
jws: &jws{privKey: privKey},
|
require.NoError(t, err)
|
||||||
validate: mockValidate,
|
|
||||||
provider: &TLSALPNProviderServer{port: "23457"},
|
solver := NewChallenge(
|
||||||
|
core,
|
||||||
|
mockValidate,
|
||||||
|
&ProviderServer{port: "23457"},
|
||||||
|
)
|
||||||
|
|
||||||
|
authz := acme.Authorization{
|
||||||
|
Identifier: acme.Identifier{
|
||||||
|
Value: domain,
|
||||||
|
},
|
||||||
|
Challenges: []acme.Challenge{
|
||||||
|
{Type: challenge.TLSALPN01.String(), Token: "tlsalpn1"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
clientChallenge := challenge{Type: string(TLSALPN01), Token: "tlsalpn1"}
|
err = solver.Solve(authz)
|
||||||
|
|
||||||
err = solver.Solve(clientChallenge, domain)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTLSALPNChallengeInvalidPort(t *testing.T) {
|
func TestChallengeInvalidPort(t *testing.T) {
|
||||||
privKey, err := rsa.GenerateKey(rand.Reader, 128)
|
_, apiURL, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 128)
|
||||||
require.NoError(t, err, "Could not generate test key")
|
require.NoError(t, err, "Could not generate test key")
|
||||||
|
|
||||||
solver := &tlsALPNChallenge{
|
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
|
||||||
jws: &jws{privKey: privKey},
|
require.NoError(t, err)
|
||||||
validate: stubValidate,
|
|
||||||
provider: &TLSALPNProviderServer{port: "123456"},
|
solver := NewChallenge(
|
||||||
|
core,
|
||||||
|
func(_ *api.Core, _ string, _ acme.Challenge) error { return nil },
|
||||||
|
&ProviderServer{port: "123456"},
|
||||||
|
)
|
||||||
|
|
||||||
|
authz := acme.Authorization{
|
||||||
|
Identifier: acme.Identifier{
|
||||||
|
Value: "localhost:123456",
|
||||||
|
},
|
||||||
|
Challenges: []acme.Challenge{
|
||||||
|
{Type: challenge.TLSALPN01.String(), Token: "tlsalpn1"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
clientChallenge := challenge{Type: string(TLSALPN01), Token: "tlsalpn1"}
|
err = solver.Solve(authz)
|
||||||
|
|
||||||
err = solver.Solve(clientChallenge, "localhost:123456")
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "invalid port")
|
assert.Contains(t, err.Error(), "invalid port")
|
||||||
assert.Contains(t, err.Error(), "123456")
|
assert.Contains(t, err.Error(), "123456")
|
470
cli_handlers.go
470
cli_handlers.go
|
@ -1,470 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/json"
|
|
||||||
"encoding/pem"
|
|
||||||
"fmt"
|
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/urfave/cli"
|
|
||||||
"github.com/xenolf/lego/acme"
|
|
||||||
"github.com/xenolf/lego/log"
|
|
||||||
"github.com/xenolf/lego/providers/dns"
|
|
||||||
"github.com/xenolf/lego/providers/http/memcached"
|
|
||||||
"github.com/xenolf/lego/providers/http/webroot"
|
|
||||||
)
|
|
||||||
|
|
||||||
func checkFolder(path string) error {
|
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
||||||
return os.MkdirAll(path, 0700)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
|
|
||||||
if c.GlobalIsSet("http-timeout") {
|
|
||||||
acme.HTTPClient = http.Client{Timeout: time.Duration(c.GlobalInt("http-timeout")) * time.Second}
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.GlobalIsSet("dns-timeout") {
|
|
||||||
acme.DNSTimeout = time.Duration(c.GlobalInt("dns-timeout")) * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(c.GlobalStringSlice("dns-resolvers")) > 0 {
|
|
||||||
var resolvers []string
|
|
||||||
for _, resolver := range c.GlobalStringSlice("dns-resolvers") {
|
|
||||||
if !strings.Contains(resolver, ":") {
|
|
||||||
resolver += ":53"
|
|
||||||
}
|
|
||||||
resolvers = append(resolvers, resolver)
|
|
||||||
}
|
|
||||||
acme.RecursiveNameservers = resolvers
|
|
||||||
}
|
|
||||||
|
|
||||||
err := checkFolder(c.GlobalString("path"))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Could not check/create path: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
conf := NewConfiguration(c)
|
|
||||||
if len(c.GlobalString("email")) == 0 {
|
|
||||||
log.Fatal("You have to pass an account (email address) to the program using --email or -m")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: move to account struct? Currently MUST pass email.
|
|
||||||
acc := NewAccount(c.GlobalString("email"), conf)
|
|
||||||
|
|
||||||
keyType, err := conf.KeyType()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
acme.UserAgent = fmt.Sprintf("lego-cli/%s", c.App.Version)
|
|
||||||
|
|
||||||
client, err := acme.NewClient(c.GlobalString("server"), acc, keyType)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Could not create client: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(c.GlobalStringSlice("exclude")) > 0 {
|
|
||||||
client.ExcludeChallenges(conf.ExcludedSolvers())
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.GlobalIsSet("webroot") {
|
|
||||||
provider, errO := webroot.NewHTTPProvider(c.GlobalString("webroot"))
|
|
||||||
if errO != nil {
|
|
||||||
log.Fatal(errO)
|
|
||||||
}
|
|
||||||
|
|
||||||
errO = client.SetChallengeProvider(acme.HTTP01, provider)
|
|
||||||
if errO != nil {
|
|
||||||
log.Fatal(errO)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --webroot=foo indicates that the user specifically want to do a HTTP challenge
|
|
||||||
// infer that the user also wants to exclude all other challenges
|
|
||||||
client.ExcludeChallenges([]acme.Challenge{acme.DNS01, acme.TLSALPN01})
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.GlobalIsSet("memcached-host") {
|
|
||||||
provider, errO := memcached.NewMemcachedProvider(c.GlobalStringSlice("memcached-host"))
|
|
||||||
if errO != nil {
|
|
||||||
log.Fatal(errO)
|
|
||||||
}
|
|
||||||
|
|
||||||
errO = client.SetChallengeProvider(acme.HTTP01, provider)
|
|
||||||
if errO != nil {
|
|
||||||
log.Fatal(errO)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --memcached-host=foo:11211 indicates that the user specifically want to do a HTTP challenge
|
|
||||||
// infer that the user also wants to exclude all other challenges
|
|
||||||
client.ExcludeChallenges([]acme.Challenge{acme.DNS01, acme.TLSALPN01})
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.GlobalIsSet("http") {
|
|
||||||
if !strings.Contains(c.GlobalString("http"), ":") {
|
|
||||||
log.Fatalf("The --http switch only accepts interface:port or :port for its argument.")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = client.SetHTTPAddress(c.GlobalString("http"))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.GlobalIsSet("tls") {
|
|
||||||
if !strings.Contains(c.GlobalString("tls"), ":") {
|
|
||||||
log.Fatalf("The --tls switch only accepts interface:port or :port for its argument.")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = client.SetTLSAddress(c.GlobalString("tls"))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.GlobalIsSet("dns") {
|
|
||||||
provider, errO := dns.NewDNSChallengeProviderByName(c.GlobalString("dns"))
|
|
||||||
if errO != nil {
|
|
||||||
log.Fatal(errO)
|
|
||||||
}
|
|
||||||
|
|
||||||
errO = client.SetChallengeProvider(acme.DNS01, provider)
|
|
||||||
if errO != nil {
|
|
||||||
log.Fatal(errO)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --dns=foo indicates that the user specifically want to do a DNS challenge
|
|
||||||
// infer that the user also wants to exclude all other challenges
|
|
||||||
client.ExcludeChallenges([]acme.Challenge{acme.HTTP01, acme.TLSALPN01})
|
|
||||||
}
|
|
||||||
|
|
||||||
if client.GetExternalAccountRequired() && !c.GlobalIsSet("eab") {
|
|
||||||
log.Fatal("Server requires External Account Binding. Use --eab with --kid and --hmac.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return conf, acc, client
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveCertRes(certRes *acme.CertificateResource, conf *Configuration) {
|
|
||||||
var domainName string
|
|
||||||
|
|
||||||
// Check filename cli parameter
|
|
||||||
if conf.context.GlobalString("filename") == "" {
|
|
||||||
// Make sure no funny chars are in the cert names (like wildcards ;))
|
|
||||||
domainName = strings.Replace(certRes.Domain, "*", "_", -1)
|
|
||||||
} else {
|
|
||||||
domainName = conf.context.GlobalString("filename")
|
|
||||||
}
|
|
||||||
|
|
||||||
// We store the certificate, private key and metadata in different files
|
|
||||||
// as web servers would not be able to work with a combined file.
|
|
||||||
certOut := filepath.Join(conf.CertPath(), domainName+".crt")
|
|
||||||
privOut := filepath.Join(conf.CertPath(), domainName+".key")
|
|
||||||
pemOut := filepath.Join(conf.CertPath(), domainName+".pem")
|
|
||||||
metaOut := filepath.Join(conf.CertPath(), domainName+".json")
|
|
||||||
issuerOut := filepath.Join(conf.CertPath(), domainName+".issuer.crt")
|
|
||||||
|
|
||||||
err := checkFolder(filepath.Dir(certOut))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Could not check/create path: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = ioutil.WriteFile(certOut, certRes.Certificate, 0600)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Unable to save Certificate for domain %s\n\t%v", certRes.Domain, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if certRes.IssuerCertificate != nil {
|
|
||||||
err = ioutil.WriteFile(issuerOut, certRes.IssuerCertificate, 0600)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Unable to save IssuerCertificate for domain %s\n\t%v", certRes.Domain, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if certRes.PrivateKey != nil {
|
|
||||||
// if we were given a CSR, we don't know the private key
|
|
||||||
err = ioutil.WriteFile(privOut, certRes.PrivateKey, 0600)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Unable to save PrivateKey for domain %s\n\t%v", certRes.Domain, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if conf.context.GlobalBool("pem") {
|
|
||||||
err = ioutil.WriteFile(pemOut, bytes.Join([][]byte{certRes.Certificate, certRes.PrivateKey}, nil), 0600)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Unable to save Certificate and PrivateKey in .pem for domain %s\n\t%v", certRes.Domain, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if conf.context.GlobalBool("pem") {
|
|
||||||
// we don't have the private key; can't write the .pem file
|
|
||||||
log.Fatalf("Unable to save pem without private key for domain %s\n\t%v; are you using a CSR?", certRes.Domain, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.MarshalIndent(certRes, "", "\t")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Unable to marshal CertResource for domain %s\n\t%v", certRes.Domain, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = ioutil.WriteFile(metaOut, jsonBytes, 0600)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Unable to save CertResource for domain %s\n\t%v", certRes.Domain, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleTOS(c *cli.Context, client *acme.Client) bool {
|
|
||||||
// Check for a global accept override
|
|
||||||
if c.GlobalBool("accept-tos") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
log.Printf("Please review the TOS at %s", client.GetToSURL())
|
|
||||||
|
|
||||||
for {
|
|
||||||
log.Println("Do you accept the TOS? Y/n")
|
|
||||||
text, err := reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Could not read from console: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
text = strings.Trim(text, "\r\n")
|
|
||||||
|
|
||||||
if text == "n" {
|
|
||||||
log.Fatal("You did not accept the TOS. Unable to proceed.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if text == "Y" || text == "y" || text == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Your input was invalid. Please answer with one of Y/y, n or by pressing enter.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func readCSRFile(filename string) (*x509.CertificateRequest, error) {
|
|
||||||
bytes, err := ioutil.ReadFile(filename)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
raw := bytes
|
|
||||||
|
|
||||||
// see if we can find a PEM-encoded CSR
|
|
||||||
var p *pem.Block
|
|
||||||
rest := bytes
|
|
||||||
for {
|
|
||||||
// decode a PEM block
|
|
||||||
p, rest = pem.Decode(rest)
|
|
||||||
|
|
||||||
// did we fail?
|
|
||||||
if p == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// did we get a CSR?
|
|
||||||
if p.Type == "CERTIFICATE REQUEST" {
|
|
||||||
raw = p.Bytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// no PEM-encoded CSR
|
|
||||||
// assume we were given a DER-encoded ASN.1 CSR
|
|
||||||
// (if this assumption is wrong, parsing these bytes will fail)
|
|
||||||
return x509.ParseCertificateRequest(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
func run(c *cli.Context) error {
|
|
||||||
var err error
|
|
||||||
|
|
||||||
conf, acc, client := setup(c)
|
|
||||||
if acc.Registration == nil {
|
|
||||||
accepted := handleTOS(c, client)
|
|
||||||
if !accepted {
|
|
||||||
log.Fatal("You did not accept the TOS. Unable to proceed.")
|
|
||||||
}
|
|
||||||
|
|
||||||
var reg *acme.RegistrationResource
|
|
||||||
|
|
||||||
if c.GlobalBool("eab") {
|
|
||||||
kid := c.GlobalString("kid")
|
|
||||||
hmacEncoded := c.GlobalString("hmac")
|
|
||||||
|
|
||||||
if kid == "" || hmacEncoded == "" {
|
|
||||||
log.Fatalf("Requires arguments --kid and --hmac.")
|
|
||||||
}
|
|
||||||
|
|
||||||
reg, err = client.RegisterWithExternalAccountBinding(
|
|
||||||
accepted,
|
|
||||||
kid,
|
|
||||||
hmacEncoded,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
reg, err = client.Register(accepted)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Could not complete registration\n\t%v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.Registration = reg
|
|
||||||
err = acc.Save()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Print("!!!! HEADS UP !!!!")
|
|
||||||
log.Printf(`
|
|
||||||
Your account credentials have been saved in your Let's Encrypt
|
|
||||||
configuration directory at "%s".
|
|
||||||
You should make a secure backup of this folder now. This
|
|
||||||
configuration directory will also contain certificates and
|
|
||||||
private keys obtained from Let's Encrypt so making regular
|
|
||||||
backups of this folder is ideal.`, conf.AccountPath(c.GlobalString("email")))
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// we require either domains or csr, but not both
|
|
||||||
hasDomains := len(c.GlobalStringSlice("domains")) > 0
|
|
||||||
hasCsr := len(c.GlobalString("csr")) > 0
|
|
||||||
if hasDomains && hasCsr {
|
|
||||||
log.Fatal("Please specify either --domains/-d or --csr/-c, but not both")
|
|
||||||
}
|
|
||||||
if !hasDomains && !hasCsr {
|
|
||||||
log.Fatal("Please specify --domains/-d (or --csr/-c if you already have a CSR)")
|
|
||||||
}
|
|
||||||
|
|
||||||
var cert *acme.CertificateResource
|
|
||||||
|
|
||||||
if hasDomains {
|
|
||||||
// obtain a certificate, generating a new private key
|
|
||||||
cert, err = client.ObtainCertificate(c.GlobalStringSlice("domains"), !c.Bool("no-bundle"), nil, c.Bool("must-staple"))
|
|
||||||
} else {
|
|
||||||
// read the CSR
|
|
||||||
var csr *x509.CertificateRequest
|
|
||||||
csr, err = readCSRFile(c.GlobalString("csr"))
|
|
||||||
if err == nil {
|
|
||||||
// obtain a certificate for this CSR
|
|
||||||
cert, err = client.ObtainCertificateForCSR(*csr, !c.Bool("no-bundle"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
// Make sure to return a non-zero exit code if ObtainSANCertificate
|
|
||||||
// returned at least one error. Due to us not returning partial
|
|
||||||
// certificate we can just exit here instead of at the end.
|
|
||||||
log.Fatalf("Could not obtain certificates\n\t%v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = checkFolder(conf.CertPath()); err != nil {
|
|
||||||
log.Fatalf("Could not check/create path: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
saveCertRes(cert, conf)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func revoke(c *cli.Context) error {
|
|
||||||
conf, acc, client := setup(c)
|
|
||||||
if acc.Registration == nil {
|
|
||||||
log.Fatalf("Account %s is not registered. Use 'run' to register a new account.\n", acc.Email)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := checkFolder(conf.CertPath()); err != nil {
|
|
||||||
log.Fatalf("Could not check/create path: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, domain := range c.GlobalStringSlice("domains") {
|
|
||||||
log.Printf("Trying to revoke certificate for domain %s", domain)
|
|
||||||
|
|
||||||
certPath := filepath.Join(conf.CertPath(), domain+".crt")
|
|
||||||
certBytes, err := ioutil.ReadFile(certPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = client.RevokeCertificate(certBytes)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error while revoking the certificate for domain %s\n\t%v", domain, err)
|
|
||||||
} else {
|
|
||||||
log.Println("Certificate was revoked.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func renew(c *cli.Context) error {
|
|
||||||
conf, acc, client := setup(c)
|
|
||||||
if acc.Registration == nil {
|
|
||||||
log.Fatalf("Account %s is not registered. Use 'run' to register a new account.\n", acc.Email)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(c.GlobalStringSlice("domains")) <= 0 {
|
|
||||||
log.Fatal("Please specify at least one domain.")
|
|
||||||
}
|
|
||||||
|
|
||||||
domain := c.GlobalStringSlice("domains")[0]
|
|
||||||
domain = strings.Replace(domain, "*", "_", -1)
|
|
||||||
|
|
||||||
// load the cert resource from files.
|
|
||||||
// We store the certificate, private key and metadata in different files
|
|
||||||
// as web servers would not be able to work with a combined file.
|
|
||||||
certPath := filepath.Join(conf.CertPath(), domain+".crt")
|
|
||||||
privPath := filepath.Join(conf.CertPath(), domain+".key")
|
|
||||||
metaPath := filepath.Join(conf.CertPath(), domain+".json")
|
|
||||||
|
|
||||||
certBytes, err := ioutil.ReadFile(certPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error while loading the certificate for domain %s\n\t%v", domain, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.IsSet("days") {
|
|
||||||
expTime, errE := acme.GetPEMCertExpiration(certBytes)
|
|
||||||
if errE != nil {
|
|
||||||
log.Printf("Could not get Certification expiration for domain %s", domain)
|
|
||||||
}
|
|
||||||
|
|
||||||
if int(time.Until(expTime).Hours()/24.0) > c.Int("days") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
metaBytes, err := ioutil.ReadFile(metaPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error while loading the meta data for domain %s\n\t%v", domain, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var certRes acme.CertificateResource
|
|
||||||
if err = json.Unmarshal(metaBytes, &certRes); err != nil {
|
|
||||||
log.Fatalf("Error while marshaling the meta data for domain %s\n\t%v", domain, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.Bool("reuse-key") {
|
|
||||||
keyBytes, errR := ioutil.ReadFile(privPath)
|
|
||||||
if errR != nil {
|
|
||||||
log.Fatalf("Error while loading the private key for domain %s\n\t%v", domain, errR)
|
|
||||||
}
|
|
||||||
certRes.PrivateKey = keyBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
certRes.Certificate = certBytes
|
|
||||||
|
|
||||||
newCert, err := client.RenewCertificate(certRes, !c.Bool("no-bundle"), c.Bool("must-staple"))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
saveCertRes(newCert, conf)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
33
cmd/account.go
Normal file
33
cmd/account.go
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
|
||||||
|
"github.com/xenolf/lego/registration"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Account represents a users local saved credentials
|
||||||
|
type Account struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Registration *registration.Resource `json:"registration"`
|
||||||
|
key crypto.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Implementation of the registration.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() *registration.Resource {
|
||||||
|
return a.Registration
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End **/
|
251
cmd/accounts_storage.go
Normal file
251
cmd/accounts_storage.go
Normal file
|
@ -0,0 +1,251 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/urfave/cli"
|
||||||
|
"github.com/xenolf/lego/lego"
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
"github.com/xenolf/lego/registration"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
baseAccountsRootFolderName = "accounts"
|
||||||
|
baseKeysFolderName = "keys"
|
||||||
|
accountFileName = "account.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccountsStorage A storage for account data.
|
||||||
|
//
|
||||||
|
// rootPath:
|
||||||
|
//
|
||||||
|
// ./.lego/accounts/
|
||||||
|
// │ └── root accounts directory
|
||||||
|
// └── "path" option
|
||||||
|
//
|
||||||
|
// rootUserPath:
|
||||||
|
//
|
||||||
|
// ./.lego/accounts/localhost_14000/hubert@hubert.com/
|
||||||
|
// │ │ │ └── userID ("email" option)
|
||||||
|
// │ │ └── CA server ("server" option)
|
||||||
|
// │ └── root accounts directory
|
||||||
|
// └── "path" option
|
||||||
|
//
|
||||||
|
// keysPath:
|
||||||
|
//
|
||||||
|
// ./.lego/accounts/localhost_14000/hubert@hubert.com/keys/
|
||||||
|
// │ │ │ │ └── root keys directory
|
||||||
|
// │ │ │ └── userID ("email" option)
|
||||||
|
// │ │ └── CA server ("server" option)
|
||||||
|
// │ └── root accounts directory
|
||||||
|
// └── "path" option
|
||||||
|
//
|
||||||
|
// accountFilePath:
|
||||||
|
//
|
||||||
|
// ./.lego/accounts/localhost_14000/hubert@hubert.com/account.json
|
||||||
|
// │ │ │ │ └── account file
|
||||||
|
// │ │ │ └── userID ("email" option)
|
||||||
|
// │ │ └── CA server ("server" option)
|
||||||
|
// │ └── root accounts directory
|
||||||
|
// └── "path" option
|
||||||
|
//
|
||||||
|
type AccountsStorage struct {
|
||||||
|
userID string
|
||||||
|
rootPath string
|
||||||
|
rootUserPath string
|
||||||
|
keysPath string
|
||||||
|
accountFilePath string
|
||||||
|
ctx *cli.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAccountsStorage Creates a new AccountsStorage.
|
||||||
|
func NewAccountsStorage(ctx *cli.Context) *AccountsStorage {
|
||||||
|
// TODO: move to account struct? Currently MUST pass email.
|
||||||
|
email := getEmail(ctx)
|
||||||
|
|
||||||
|
serverURL, err := url.Parse(ctx.GlobalString("server"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootPath := filepath.Join(ctx.GlobalString("path"), baseAccountsRootFolderName)
|
||||||
|
serverPath := strings.NewReplacer(":", "_", "/", string(os.PathSeparator)).Replace(serverURL.Host)
|
||||||
|
accountsPath := filepath.Join(rootPath, serverPath)
|
||||||
|
rootUserPath := filepath.Join(accountsPath, email)
|
||||||
|
|
||||||
|
return &AccountsStorage{
|
||||||
|
userID: email,
|
||||||
|
rootPath: rootPath,
|
||||||
|
rootUserPath: rootUserPath,
|
||||||
|
keysPath: filepath.Join(rootUserPath, baseKeysFolderName),
|
||||||
|
accountFilePath: filepath.Join(rootUserPath, accountFileName),
|
||||||
|
ctx: ctx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountsStorage) ExistsAccountFilePath() bool {
|
||||||
|
accountFile := filepath.Join(s.rootUserPath, accountFileName)
|
||||||
|
if _, err := os.Stat(accountFile); os.IsNotExist(err) {
|
||||||
|
return false
|
||||||
|
} else if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountsStorage) GetRootPath() string {
|
||||||
|
return s.rootPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountsStorage) GetRootUserPath() string {
|
||||||
|
return s.rootUserPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountsStorage) GetUserID() string {
|
||||||
|
return s.userID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountsStorage) Save(account *Account) error {
|
||||||
|
jsonBytes, err := json.MarshalIndent(account, "", "\t")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ioutil.WriteFile(s.accountFilePath, jsonBytes, filePerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountsStorage) LoadAccount(privateKey crypto.PrivateKey) *Account {
|
||||||
|
fileBytes, err := ioutil.ReadFile(s.accountFilePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not load file for account %s -> %v", s.userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var account Account
|
||||||
|
err = json.Unmarshal(fileBytes, &account)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not parse file for account %s -> %v", s.userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
account.key = privateKey
|
||||||
|
|
||||||
|
if account.Registration == nil || account.Registration.Body.Status == "" {
|
||||||
|
reg, err := tryRecoverRegistration(s.ctx, privateKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not load account for %s. Registration is nil -> %#v", s.userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
account.Registration = reg
|
||||||
|
err = s.Save(&account)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not save account for %s. Registration is nil -> %#v", s.userID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &account
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountsStorage) GetPrivateKey() crypto.PrivateKey {
|
||||||
|
accKeyPath := filepath.Join(s.keysPath, s.userID+".key")
|
||||||
|
|
||||||
|
if _, err := os.Stat(accKeyPath); os.IsNotExist(err) {
|
||||||
|
log.Printf("No key found for account %s. Generating a curve P384 EC key.", s.userID)
|
||||||
|
s.createKeysFolder()
|
||||||
|
|
||||||
|
privateKey, err := generatePrivateKey(accKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not generate RSA private account key for account %s: %v", s.userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Saved key to %s", accKeyPath)
|
||||||
|
return privateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, err := loadPrivateKey(accKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not load RSA private key from file %s: %v", accKeyPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return privateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountsStorage) createKeysFolder() {
|
||||||
|
if err := createNonExistingFolder(s.keysPath); err != nil {
|
||||||
|
log.Fatalf("Could not check/create directory for account %s: %v", s.userID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePrivateKey(file string) (crypto.PrivateKey, error) {
|
||||||
|
privateKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keyBytes, err := x509.MarshalECPrivateKey(privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pemKey := pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}
|
||||||
|
|
||||||
|
certOut, err := os.Create(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer certOut.Close()
|
||||||
|
|
||||||
|
err = pem.Encode(certOut, &pemKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return privateKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPrivateKey(file string) (crypto.PrivateKey, error) {
|
||||||
|
keyBytes, err := ioutil.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keyBlock, _ := pem.Decode(keyBytes)
|
||||||
|
|
||||||
|
switch keyBlock.Type {
|
||||||
|
case "RSA PRIVATE KEY":
|
||||||
|
return x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
|
||||||
|
case "EC PRIVATE KEY":
|
||||||
|
return x509.ParseECPrivateKey(keyBlock.Bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("unknown private key type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryRecoverRegistration(ctx *cli.Context, privateKey crypto.PrivateKey) (*registration.Resource, error) {
|
||||||
|
// couldn't load account but got a key. Try to look the account up.
|
||||||
|
config := lego.NewConfig(&Account{key: privateKey})
|
||||||
|
config.CADirURL = ctx.GlobalString("server")
|
||||||
|
config.UserAgent = fmt.Sprintf("lego-cli/%s", ctx.App.Version)
|
||||||
|
|
||||||
|
client, err := lego.NewClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, err := client.Registration.ResolveAccountByKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reg, nil
|
||||||
|
}
|
204
cmd/certs_storage.go
Normal file
204
cmd/certs_storage.go
Normal file
|
@ -0,0 +1,204 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/urfave/cli"
|
||||||
|
"github.com/xenolf/lego/certcrypto"
|
||||||
|
"github.com/xenolf/lego/certificate"
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
"golang.org/x/net/idna"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
baseCertificatesFolderName = "certificates"
|
||||||
|
baseArchivesFolderName = "archives"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CertificatesStorage a certificates storage.
|
||||||
|
//
|
||||||
|
// rootPath:
|
||||||
|
//
|
||||||
|
// ./.lego/certificates/
|
||||||
|
// │ └── root certificates directory
|
||||||
|
// └── "path" option
|
||||||
|
//
|
||||||
|
// archivePath:
|
||||||
|
//
|
||||||
|
// ./.lego/archives/
|
||||||
|
// │ └── archived certificates directory
|
||||||
|
// └── "path" option
|
||||||
|
//
|
||||||
|
type CertificatesStorage struct {
|
||||||
|
rootPath string
|
||||||
|
archivePath string
|
||||||
|
pem bool
|
||||||
|
filename string // Deprecated
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCertificatesStorage create a new certificates storage.
|
||||||
|
func NewCertificatesStorage(ctx *cli.Context) *CertificatesStorage {
|
||||||
|
return &CertificatesStorage{
|
||||||
|
rootPath: filepath.Join(ctx.GlobalString("path"), baseCertificatesFolderName),
|
||||||
|
archivePath: filepath.Join(ctx.GlobalString("path"), baseArchivesFolderName),
|
||||||
|
pem: ctx.GlobalBool("pem"),
|
||||||
|
filename: ctx.GlobalString("filename"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CertificatesStorage) CreateRootFolder() {
|
||||||
|
err := createNonExistingFolder(s.rootPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not check/create path: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CertificatesStorage) CreateArchiveFolder() {
|
||||||
|
err := createNonExistingFolder(s.archivePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not check/create path: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CertificatesStorage) GetRootPath() string {
|
||||||
|
return s.rootPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CertificatesStorage) SaveResource(certRes *certificate.Resource) {
|
||||||
|
domain := certRes.Domain
|
||||||
|
|
||||||
|
// We store the certificate, private key and metadata in different files
|
||||||
|
// as web servers would not be able to work with a combined file.
|
||||||
|
err := s.WriteFile(domain, ".crt", certRes.Certificate)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Unable to save Certificate for domain %s\n\t%v", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if certRes.IssuerCertificate != nil {
|
||||||
|
err = s.WriteFile(domain, ".issuer.crt", certRes.IssuerCertificate)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Unable to save IssuerCertificate for domain %s\n\t%v", domain, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if certRes.PrivateKey != nil {
|
||||||
|
// if we were given a CSR, we don't know the private key
|
||||||
|
err = s.WriteFile(domain, ".key", certRes.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Unable to save PrivateKey for domain %s\n\t%v", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.pem {
|
||||||
|
err = s.WriteFile(domain, ".pem", bytes.Join([][]byte{certRes.Certificate, certRes.PrivateKey}, nil))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Unable to save Certificate and PrivateKey in .pem for domain %s\n\t%v", domain, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if s.pem {
|
||||||
|
// we don't have the private key; can't write the .pem file
|
||||||
|
log.Fatalf("Unable to save pem without private key for domain %s\n\t%v; are you using a CSR?", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.MarshalIndent(certRes, "", "\t")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Unable to marshal CertResource for domain %s\n\t%v", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.WriteFile(domain, ".json", jsonBytes)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Unable to save CertResource for domain %s\n\t%v", domain, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CertificatesStorage) ReadResource(domain string) certificate.Resource {
|
||||||
|
raw, err := s.ReadFile(domain, ".json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error while loading the meta data for domain %s\n\t%v", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resource certificate.Resource
|
||||||
|
if err = json.Unmarshal(raw, &resource); err != nil {
|
||||||
|
log.Fatalf("Error while marshaling the meta data for domain %s\n\t%v", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resource
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CertificatesStorage) ExistsFile(domain, extension string) bool {
|
||||||
|
filename := sanitizedDomain(domain) + extension
|
||||||
|
filePath := filepath.Join(s.rootPath, filename)
|
||||||
|
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
return false
|
||||||
|
} else if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CertificatesStorage) ReadFile(domain, extension string) ([]byte, error) {
|
||||||
|
filename := sanitizedDomain(domain) + extension
|
||||||
|
filePath := filepath.Join(s.rootPath, filename)
|
||||||
|
|
||||||
|
return ioutil.ReadFile(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CertificatesStorage) ReadCertificate(domain, extension string) ([]*x509.Certificate, error) {
|
||||||
|
content, err := s.ReadFile(domain, extension)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The input may be a bundle or a single certificate.
|
||||||
|
return certcrypto.ParsePEMBundle(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CertificatesStorage) WriteFile(domain, extension string, data []byte) error {
|
||||||
|
var baseFileName string
|
||||||
|
if s.filename != "" {
|
||||||
|
baseFileName = s.filename
|
||||||
|
} else {
|
||||||
|
baseFileName = sanitizedDomain(domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(s.rootPath, baseFileName+extension)
|
||||||
|
|
||||||
|
return ioutil.WriteFile(filePath, data, filePerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CertificatesStorage) MoveToArchive(domain string) error {
|
||||||
|
matches, err := filepath.Glob(filepath.Join(s.rootPath, sanitizedDomain(domain)+".*"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, oldFile := range matches {
|
||||||
|
date := strconv.FormatInt(time.Now().Unix(), 10)
|
||||||
|
filename := date + "." + filepath.Base(oldFile)
|
||||||
|
newFile := filepath.Join(s.archivePath, filename)
|
||||||
|
|
||||||
|
err = os.Rename(oldFile, newFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizedDomain Make sure no funny chars are in the cert names (like wildcards ;))
|
||||||
|
func sanitizedDomain(domain string) string {
|
||||||
|
safe, err := idna.ToASCII(strings.Replace(domain, "*", "_", -1))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
return safe
|
||||||
|
}
|
14
cmd/cmd.go
Normal file
14
cmd/cmd.go
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import "github.com/urfave/cli"
|
||||||
|
|
||||||
|
// CreateCommands Creates all CLI commands
|
||||||
|
func CreateCommands() []cli.Command {
|
||||||
|
return []cli.Command{
|
||||||
|
createRun(),
|
||||||
|
createRevoke(),
|
||||||
|
createRenew(),
|
||||||
|
createDNSHelp(),
|
||||||
|
createList(),
|
||||||
|
}
|
||||||
|
}
|
23
cmd/cmd_before.go
Normal file
23
cmd/cmd_before.go
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/urfave/cli"
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Before(ctx *cli.Context) error {
|
||||||
|
if len(ctx.GlobalString("path")) == 0 {
|
||||||
|
log.Fatal("Could not determine current working directory. Please pass --path.")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := createNonExistingFolder(ctx.GlobalString("path"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not check/create path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ctx.GlobalString("server")) == 0 {
|
||||||
|
log.Fatal("Could not determine current working server. Please pass --server.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -1,189 +1,18 @@
|
||||||
// Let's Encrypt client to go!
|
package cmd
|
||||||
// CLI application for generating Let's Encrypt certificates using the ACME package.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
"github.com/urfave/cli"
|
"github.com/urfave/cli"
|
||||||
"github.com/xenolf/lego/acme"
|
|
||||||
"github.com/xenolf/lego/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
func createDNSHelp() cli.Command {
|
||||||
version = "dev"
|
return cli.Command{
|
||||||
)
|
Name: "dnshelp",
|
||||||
|
Usage: "Shows additional help for the --dns global option",
|
||||||
func main() {
|
Action: dnsHelp,
|
||||||
app := cli.NewApp()
|
|
||||||
app.Name = "lego"
|
|
||||||
app.Usage = "Let's Encrypt client written in Go"
|
|
||||||
|
|
||||||
app.Version = version
|
|
||||||
|
|
||||||
acme.UserAgent = "lego/" + app.Version
|
|
||||||
|
|
||||||
defaultPath := ""
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
if err == nil {
|
|
||||||
defaultPath = filepath.Join(cwd, ".lego")
|
|
||||||
}
|
|
||||||
|
|
||||||
app.Before = func(c *cli.Context) error {
|
|
||||||
if c.GlobalString("path") == "" {
|
|
||||||
log.Fatal("Could not determine current working directory. Please pass --path.")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
app.Commands = []cli.Command{
|
|
||||||
{
|
|
||||||
Name: "run",
|
|
||||||
Usage: "Register an account, then create and install a certificate",
|
|
||||||
Action: run,
|
|
||||||
Flags: []cli.Flag{
|
|
||||||
cli.BoolFlag{
|
|
||||||
Name: "no-bundle",
|
|
||||||
Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.",
|
|
||||||
},
|
|
||||||
cli.BoolFlag{
|
|
||||||
Name: "must-staple",
|
|
||||||
Usage: "Include the OCSP must staple TLS extension in the CSR and generated certificate. Only works if the CSR is generated by lego.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "revoke",
|
|
||||||
Usage: "Revoke a certificate",
|
|
||||||
Action: revoke,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "renew",
|
|
||||||
Usage: "Renew a certificate",
|
|
||||||
Action: renew,
|
|
||||||
Flags: []cli.Flag{
|
|
||||||
cli.IntFlag{
|
|
||||||
Name: "days",
|
|
||||||
Value: 0,
|
|
||||||
Usage: "The number of days left on a certificate to renew it.",
|
|
||||||
},
|
|
||||||
cli.BoolFlag{
|
|
||||||
Name: "reuse-key",
|
|
||||||
Usage: "Used to indicate you want to reuse your current private key for the new certificate.",
|
|
||||||
},
|
|
||||||
cli.BoolFlag{
|
|
||||||
Name: "no-bundle",
|
|
||||||
Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.",
|
|
||||||
},
|
|
||||||
cli.BoolFlag{
|
|
||||||
Name: "must-staple",
|
|
||||||
Usage: "Include the OCSP must staple TLS extension in the CSR and generated certificate. Only works if the CSR is generated by lego.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "dnshelp",
|
|
||||||
Usage: "Shows additional help for the --dns global option",
|
|
||||||
Action: dnsHelp,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
app.Flags = []cli.Flag{
|
|
||||||
cli.StringSliceFlag{
|
|
||||||
Name: "domains, d",
|
|
||||||
Usage: "Add a domain to the process. Can be specified multiple times.",
|
|
||||||
},
|
|
||||||
cli.StringFlag{
|
|
||||||
Name: "csr, c",
|
|
||||||
Usage: "Certificate signing request filename, if an external CSR is to be used",
|
|
||||||
},
|
|
||||||
cli.StringFlag{
|
|
||||||
Name: "server, s",
|
|
||||||
Value: "https://acme-v02.api.letsencrypt.org/directory",
|
|
||||||
Usage: "CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client.",
|
|
||||||
},
|
|
||||||
cli.StringFlag{
|
|
||||||
Name: "email, m",
|
|
||||||
Usage: "Email used for registration and recovery contact.",
|
|
||||||
},
|
|
||||||
cli.StringFlag{
|
|
||||||
Name: "filename",
|
|
||||||
Usage: "Filename of the generated certificate",
|
|
||||||
},
|
|
||||||
cli.BoolFlag{
|
|
||||||
Name: "accept-tos, a",
|
|
||||||
Usage: "By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service.",
|
|
||||||
},
|
|
||||||
cli.BoolFlag{
|
|
||||||
Name: "eab",
|
|
||||||
Usage: "Use External Account Binding for account registration. Requires --kid and --hmac.",
|
|
||||||
},
|
|
||||||
cli.StringFlag{
|
|
||||||
Name: "kid",
|
|
||||||
Usage: "Key identifier from External CA. Used for External Account Binding.",
|
|
||||||
},
|
|
||||||
cli.StringFlag{
|
|
||||||
Name: "hmac",
|
|
||||||
Usage: "MAC key from External CA. Should be in Base64 URL Encoding without padding format. Used for External Account Binding.",
|
|
||||||
},
|
|
||||||
cli.StringFlag{
|
|
||||||
Name: "key-type, k",
|
|
||||||
Value: "rsa2048",
|
|
||||||
Usage: "Key type to use for private keys. Supported: rsa2048, rsa4096, rsa8192, ec256, ec384",
|
|
||||||
},
|
|
||||||
cli.StringFlag{
|
|
||||||
Name: "path",
|
|
||||||
Usage: "Directory to use for storing the data",
|
|
||||||
Value: defaultPath,
|
|
||||||
},
|
|
||||||
cli.StringSliceFlag{
|
|
||||||
Name: "exclude, x",
|
|
||||||
Usage: "Explicitly disallow solvers by name from being used. Solvers: \"http-01\", \"dns-01\", \"tls-alpn-01\".",
|
|
||||||
},
|
|
||||||
cli.StringFlag{
|
|
||||||
Name: "webroot",
|
|
||||||
Usage: "Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge",
|
|
||||||
},
|
|
||||||
cli.StringSliceFlag{
|
|
||||||
Name: "memcached-host",
|
|
||||||
Usage: "Set the memcached host(s) to use for HTTP based challenges. Challenges will be written to all specified hosts.",
|
|
||||||
},
|
|
||||||
cli.StringFlag{
|
|
||||||
Name: "http",
|
|
||||||
Usage: "Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port",
|
|
||||||
},
|
|
||||||
cli.StringFlag{
|
|
||||||
Name: "tls",
|
|
||||||
Usage: "Set the port and interface to use for TLS based challenges to listen on. Supported: interface:port or :port",
|
|
||||||
},
|
|
||||||
cli.StringFlag{
|
|
||||||
Name: "dns",
|
|
||||||
Usage: "Solve a DNS challenge using the specified provider. Disables all other challenges. Run 'lego dnshelp' for help on usage.",
|
|
||||||
},
|
|
||||||
cli.IntFlag{
|
|
||||||
Name: "http-timeout",
|
|
||||||
Usage: "Set the HTTP timeout value to a specific value in seconds. The default is 10 seconds.",
|
|
||||||
},
|
|
||||||
cli.IntFlag{
|
|
||||||
Name: "dns-timeout",
|
|
||||||
Usage: "Set the DNS timeout value to a specific value in seconds. The default is 10 seconds.",
|
|
||||||
},
|
|
||||||
cli.StringSliceFlag{
|
|
||||||
Name: "dns-resolvers",
|
|
||||||
Usage: "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.",
|
|
||||||
},
|
|
||||||
cli.BoolFlag{
|
|
||||||
Name: "pem",
|
|
||||||
Usage: "Generate a .pem file by concatenating the .key and .crt files together.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err = app.Run(os.Args)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -209,7 +38,7 @@ Here is an example bash command using the CloudFlare DNS provider:
|
||||||
fmt.Fprintln(w, "\tbluecat:\tBLUECAT_SERVER_URL, BLUECAT_USER_NAME, BLUECAT_PASSWORD, BLUECAT_CONFIG_NAME, BLUECAT_DNS_VIEW")
|
fmt.Fprintln(w, "\tbluecat:\tBLUECAT_SERVER_URL, BLUECAT_USER_NAME, BLUECAT_PASSWORD, BLUECAT_CONFIG_NAME, BLUECAT_DNS_VIEW")
|
||||||
fmt.Fprintln(w, "\tcloudflare:\tCLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY")
|
fmt.Fprintln(w, "\tcloudflare:\tCLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY")
|
||||||
fmt.Fprintln(w, "\tcloudxns:\tCLOUDXNS_API_KEY, CLOUDXNS_SECRET_KEY")
|
fmt.Fprintln(w, "\tcloudxns:\tCLOUDXNS_API_KEY, CLOUDXNS_SECRET_KEY")
|
||||||
fmt.Fprintln(w, "\tconoha:\tCONOHA_REGION, CONOHA_TENANT_ID, CONOHA_API_USERNAME, CONOHA_API_PASSWORD")
|
fmt.Fprintln(w, "\tconoha:\tCONOHA_TENANT_ID, CONOHA_API_USERNAME, CONOHA_API_PASSWORD")
|
||||||
fmt.Fprintln(w, "\tdigitalocean:\tDO_AUTH_TOKEN")
|
fmt.Fprintln(w, "\tdigitalocean:\tDO_AUTH_TOKEN")
|
||||||
fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_EMAIL, DNSIMPLE_OAUTH_TOKEN")
|
fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_EMAIL, DNSIMPLE_OAUTH_TOKEN")
|
||||||
fmt.Fprintln(w, "\tdnsmadeeasy:\tDNSMADEEASY_API_KEY, DNSMADEEASY_API_SECRET")
|
fmt.Fprintln(w, "\tdnsmadeeasy:\tDNSMADEEASY_API_KEY, DNSMADEEASY_API_SECRET")
|
||||||
|
@ -246,11 +75,12 @@ Here is an example bash command using the CloudFlare DNS provider:
|
||||||
fmt.Fprintln(w, "\trfc2136:\tRFC2136_TSIG_KEY, RFC2136_TSIG_SECRET,\n\t\tRFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER")
|
fmt.Fprintln(w, "\trfc2136:\tRFC2136_TSIG_KEY, RFC2136_TSIG_SECRET,\n\t\tRFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER")
|
||||||
fmt.Fprintln(w, "\troute53:\tAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_HOSTED_ZONE_ID")
|
fmt.Fprintln(w, "\troute53:\tAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_HOSTED_ZONE_ID")
|
||||||
fmt.Fprintln(w, "\tsakuracloud:\tSAKURACLOUD_ACCESS_TOKEN, SAKURACLOUD_ACCESS_TOKEN_SECRET")
|
fmt.Fprintln(w, "\tsakuracloud:\tSAKURACLOUD_ACCESS_TOKEN, SAKURACLOUD_ACCESS_TOKEN_SECRET")
|
||||||
fmt.Fprintln(w, "\tstackpath:\tSTACKPATH_CLIENT_ID, STACKPATH_CLIENT_SECRET, STACKPATH_STACK_ID")
|
|
||||||
fmt.Fprintln(w, "\tselectel:\tSELECTEL_API_TOKEN")
|
fmt.Fprintln(w, "\tselectel:\tSELECTEL_API_TOKEN")
|
||||||
|
fmt.Fprintln(w, "\tstackpath:\tSTACKPATH_CLIENT_ID, STACKPATH_CLIENT_SECRET, STACKPATH_STACK_ID")
|
||||||
|
fmt.Fprintln(w, "\ttransip:\tTRANSIP_ACCOUNT_NAME, TRANSIP_PRIVATE_KEY_PATH")
|
||||||
fmt.Fprintln(w, "\tvegadns:\tSECRET_VEGADNS_KEY, SECRET_VEGADNS_SECRET, VEGADNS_URL")
|
fmt.Fprintln(w, "\tvegadns:\tSECRET_VEGADNS_KEY, SECRET_VEGADNS_SECRET, VEGADNS_URL")
|
||||||
fmt.Fprintln(w, "\tvultr:\tVULTR_API_KEY")
|
|
||||||
fmt.Fprintln(w, "\tvscale:\tVSCALE_API_TOKEN")
|
fmt.Fprintln(w, "\tvscale:\tVSCALE_API_TOKEN")
|
||||||
|
fmt.Fprintln(w, "\tvultr:\tVULTR_API_KEY")
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
fmt.Fprintln(w, "Additional configuration environment variables:")
|
fmt.Fprintln(w, "Additional configuration environment variables:")
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
|
@ -260,21 +90,22 @@ Here is an example bash command using the CloudFlare DNS provider:
|
||||||
fmt.Fprintln(w, "\tbluecat:\tBLUECAT_POLLING_INTERVAL, BLUECAT_PROPAGATION_TIMEOUT, BLUECAT_TTL, BLUECAT_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tbluecat:\tBLUECAT_POLLING_INTERVAL, BLUECAT_PROPAGATION_TIMEOUT, BLUECAT_TTL, BLUECAT_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tcloudflare:\tCLOUDFLARE_POLLING_INTERVAL, CLOUDFLARE_PROPAGATION_TIMEOUT, CLOUDFLARE_TTL, CLOUDFLARE_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tcloudflare:\tCLOUDFLARE_POLLING_INTERVAL, CLOUDFLARE_PROPAGATION_TIMEOUT, CLOUDFLARE_TTL, CLOUDFLARE_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tcloudxns:\tCLOUDXNS_POLLING_INTERVAL, CLOUDXNS_PROPAGATION_TIMEOUT, CLOUDXNS_TTL, CLOUDXNS_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tcloudxns:\tCLOUDXNS_POLLING_INTERVAL, CLOUDXNS_PROPAGATION_TIMEOUT, CLOUDXNS_TTL, CLOUDXNS_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tconoha:\tCONOHA_POLLING_INTERVAL, CONOHA_PROPAGATION_TIMEOUT, CONOHA_TTL, CONOHA_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tconoha:\tCONOHA_POLLING_INTERVAL, CONOHA_PROPAGATION_TIMEOUT, CONOHA_TTL, CONOHA_HTTP_TIMEOUT, CONOHA_REGION")
|
||||||
fmt.Fprintln(w, "\tdigitalocean:\tDO_POLLING_INTERVAL, DO_PROPAGATION_TIMEOUT, DO_TTL, DO_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tdigitalocean:\tDO_POLLING_INTERVAL, DO_PROPAGATION_TIMEOUT, DO_TTL, DO_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_TTL, DNSIMPLE_POLLING_INTERVAL, DNSIMPLE_PROPAGATION_TIMEOUT")
|
fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_TTL, DNSIMPLE_POLLING_INTERVAL, DNSIMPLE_PROPAGATION_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tdnsmadeeasy:\tDNSMADEEASY_POLLING_INTERVAL, DNSMADEEASY_PROPAGATION_TIMEOUT, DNSMADEEASY_TTL, DNSMADEEASY_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tdnsmadeeasy:\tDNSMADEEASY_POLLING_INTERVAL, DNSMADEEASY_PROPAGATION_TIMEOUT, DNSMADEEASY_TTL, DNSMADEEASY_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tdnspod:\tDNSPOD_POLLING_INTERVAL, DNSPOD_PROPAGATION_TIMEOUT, DNSPOD_TTL, DNSPOD_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tdnspod:\tDNSPOD_POLLING_INTERVAL, DNSPOD_PROPAGATION_TIMEOUT, DNSPOD_TTL, DNSPOD_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tdreamhost:\tDREAMHOST_POLLING_INTERVAL, DREAMHOST_PROPAGATION_TIMEOUT, DREAMHOST_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tdreamhost:\tDREAMHOST_POLLING_INTERVAL, DREAMHOST_PROPAGATION_TIMEOUT, DREAMHOST_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tduckdns:\tDUCKDNS_POLLING_INTERVAL, DUCKDNS_PROPAGATION_TIMEOUT, DUCKDNS_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tduckdns:\tDUCKDNS_POLLING_INTERVAL, DUCKDNS_PROPAGATION_TIMEOUT, DUCKDNS_HTTP_TIMEOUT, DUCKDNS_SEQUENCE_INTERVAL")
|
||||||
fmt.Fprintln(w, "\tdyn:\tDYN_POLLING_INTERVAL, DYN_PROPAGATION_TIMEOUT, DYN_TTL, DYN_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tdyn:\tDYN_POLLING_INTERVAL, DYN_PROPAGATION_TIMEOUT, DYN_TTL, DYN_HTTP_TIMEOUT")
|
||||||
|
fmt.Fprintln(w, "\texec:\tEXEC_POLLING_INTERVAL, EXEC_PROPAGATION_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\texoscale:\tEXOSCALE_POLLING_INTERVAL, EXOSCALE_PROPAGATION_TIMEOUT, EXOSCALE_TTL, EXOSCALE_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\texoscale:\tEXOSCALE_POLLING_INTERVAL, EXOSCALE_PROPAGATION_TIMEOUT, EXOSCALE_TTL, EXOSCALE_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tfastdns:\tAKAMAI_POLLING_INTERVAL, AKAMAI_PROPAGATION_TIMEOUT, AKAMAI_TTL")
|
fmt.Fprintln(w, "\tfastdns:\tAKAMAI_POLLING_INTERVAL, AKAMAI_PROPAGATION_TIMEOUT, AKAMAI_TTL")
|
||||||
fmt.Fprintln(w, "\tgandi:\tGANDI_POLLING_INTERVAL, GANDI_PROPAGATION_TIMEOUT, GANDI_TTL, GANDI_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tgandi:\tGANDI_POLLING_INTERVAL, GANDI_PROPAGATION_TIMEOUT, GANDI_TTL, GANDI_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tgandiv5:\tGANDIV5_POLLING_INTERVAL, GANDIV5_PROPAGATION_TIMEOUT, GANDIV5_TTL, GANDIV5_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tgandiv5:\tGANDIV5_POLLING_INTERVAL, GANDIV5_PROPAGATION_TIMEOUT, GANDIV5_TTL, GANDIV5_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tgcloud:\tGCE_POLLING_INTERVAL, GCE_PROPAGATION_TIMEOUT, GCE_TTL")
|
fmt.Fprintln(w, "\tgcloud:\tGCE_POLLING_INTERVAL, GCE_PROPAGATION_TIMEOUT, GCE_TTL")
|
||||||
fmt.Fprintln(w, "\tglesys:\tGLESYS_POLLING_INTERVAL, GLESYS_PROPAGATION_TIMEOUT, GLESYS_TTL, GLESYS_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tglesys:\tGLESYS_POLLING_INTERVAL, GLESYS_PROPAGATION_TIMEOUT, GLESYS_TTL, GLESYS_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tgodaddy:\tGODADDY_POLLING_INTERVAL, GODADDY_PROPAGATION_TIMEOUT, GODADDY_TTL, GODADDY_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tgodaddy:\tGODADDY_POLLING_INTERVAL, GODADDY_PROPAGATION_TIMEOUT, GODADDY_TTL, GODADDY_HTTP_TIMEOUT, GODADDY_SEQUENCE_INTERVAL")
|
||||||
fmt.Fprintln(w, "\thostingde:\tHOSTINGDE_POLLING_INTERVAL, HOSTINGDE_PROPAGATION_TIMEOUT, HOSTINGDE_TTL, HOSTINGDE_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\thostingde:\tHOSTINGDE_POLLING_INTERVAL, HOSTINGDE_PROPAGATION_TIMEOUT, HOSTINGDE_TTL, HOSTINGDE_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\thttpreq:\t,HTTPREQ_POLLING_INTERVAL, HTTPREQ_PROPAGATION_TIMEOUT, HTTPREQ_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\thttpreq:\t,HTTPREQ_POLLING_INTERVAL, HTTPREQ_PROPAGATION_TIMEOUT, HTTPREQ_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tiij:\tIIJ_POLLING_INTERVAL, IIJ_PROPAGATION_TIMEOUT, IIJ_TTL")
|
fmt.Fprintln(w, "\tiij:\tIIJ_POLLING_INTERVAL, IIJ_PROPAGATION_TIMEOUT, IIJ_TTL")
|
||||||
|
@ -282,6 +113,7 @@ Here is an example bash command using the CloudFlare DNS provider:
|
||||||
fmt.Fprintln(w, "\tlightsail:\tLIGHTSAIL_POLLING_INTERVAL, LIGHTSAIL_PROPAGATION_TIMEOUT")
|
fmt.Fprintln(w, "\tlightsail:\tLIGHTSAIL_POLLING_INTERVAL, LIGHTSAIL_PROPAGATION_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tlinode:\tLINODE_POLLING_INTERVAL, LINODE_TTL, LINODE_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tlinode:\tLINODE_POLLING_INTERVAL, LINODE_TTL, LINODE_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tlinodev4:\tLINODE_POLLING_INTERVAL, LINODE_TTL, LINODE_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tlinodev4:\tLINODE_POLLING_INTERVAL, LINODE_TTL, LINODE_HTTP_TIMEOUT")
|
||||||
|
fmt.Fprintln(w, "\tmydnsjp:\tMYDNSJP_PROPAGATION_TIMEOUT, MYDNSJP_POLLING_INTERVAL, MYDNSJP_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tnamecheap:\tNAMECHEAP_POLLING_INTERVAL, NAMECHEAP_PROPAGATION_TIMEOUT, NAMECHEAP_TTL, NAMECHEAP_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tnamecheap:\tNAMECHEAP_POLLING_INTERVAL, NAMECHEAP_PROPAGATION_TIMEOUT, NAMECHEAP_TTL, NAMECHEAP_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tnamedotcom:\tNAMECOM_POLLING_INTERVAL, NAMECOM_PROPAGATION_TIMEOUT, NAMECOM_TTL, NAMECOM_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tnamedotcom:\tNAMECOM_POLLING_INTERVAL, NAMECOM_PROPAGATION_TIMEOUT, NAMECOM_TTL, NAMECOM_HTTP_TIMEOUT")
|
||||||
fmt.Fprintln(w, "\tnetcup:\tNETCUP_POLLING_INTERVAL, NETCUP_PROPAGATION_TIMEOUT, NETCUP_TTL, NETCUP_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tnetcup:\tNETCUP_POLLING_INTERVAL, NETCUP_PROPAGATION_TIMEOUT, NETCUP_TTL, NETCUP_HTTP_TIMEOUT")
|
||||||
|
@ -294,11 +126,12 @@ Here is an example bash command using the CloudFlare DNS provider:
|
||||||
fmt.Fprintln(w, "\trfc2136:\tRFC2136_POLLING_INTERVAL, RFC2136_PROPAGATION_TIMEOUT, RFC2136_TTL")
|
fmt.Fprintln(w, "\trfc2136:\tRFC2136_POLLING_INTERVAL, RFC2136_PROPAGATION_TIMEOUT, RFC2136_TTL")
|
||||||
fmt.Fprintln(w, "\troute53:\tAWS_POLLING_INTERVAL, AWS_PROPAGATION_TIMEOUT, AWS_TTL")
|
fmt.Fprintln(w, "\troute53:\tAWS_POLLING_INTERVAL, AWS_PROPAGATION_TIMEOUT, AWS_TTL")
|
||||||
fmt.Fprintln(w, "\tsakuracloud:\tSAKURACLOUD_POLLING_INTERVAL, SAKURACLOUD_PROPAGATION_TIMEOUT, SAKURACLOUD_TTL")
|
fmt.Fprintln(w, "\tsakuracloud:\tSAKURACLOUD_POLLING_INTERVAL, SAKURACLOUD_PROPAGATION_TIMEOUT, SAKURACLOUD_TTL")
|
||||||
fmt.Fprintln(w, "\tstackpath:\tSTACKPATH_POLLING_INTERVAL, STACKPATH_PROPAGATION_TIMEOUT, STACKPATH_TTL")
|
|
||||||
fmt.Fprintln(w, "\tselectel:\tSELECTEL_BASE_URL, SELECTEL_TTL, SELECTEL_PROPAGATION_TIMEOUT, SELECTEL_POLLING_INTERVAL, SELECTEL_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tselectel:\tSELECTEL_BASE_URL, SELECTEL_TTL, SELECTEL_PROPAGATION_TIMEOUT, SELECTEL_POLLING_INTERVAL, SELECTEL_HTTP_TIMEOUT")
|
||||||
|
fmt.Fprintln(w, "\ttransip:\tTRANSIP_POLLING_INTERVAL, TRANSIP_PROPAGATION_TIMEOUT, TRANSIP_TTL")
|
||||||
|
fmt.Fprintln(w, "\tstackpath:\tSTACKPATH_POLLING_INTERVAL, STACKPATH_PROPAGATION_TIMEOUT, STACKPATH_TTL")
|
||||||
fmt.Fprintln(w, "\tvegadns:\tVEGADNS_POLLING_INTERVAL, VEGADNS_PROPAGATION_TIMEOUT, VEGADNS_TTL")
|
fmt.Fprintln(w, "\tvegadns:\tVEGADNS_POLLING_INTERVAL, VEGADNS_PROPAGATION_TIMEOUT, VEGADNS_TTL")
|
||||||
fmt.Fprintln(w, "\tvultr:\tVULTR_POLLING_INTERVAL, VULTR_PROPAGATION_TIMEOUT, VULTR_TTL, VULTR_HTTP_TIMEOUT")
|
|
||||||
fmt.Fprintln(w, "\tvscale:\tVSCALE_BASE_URL, VSCALE_TTL, VSCALE_PROPAGATION_TIMEOUT, VSCALE_POLLING_INTERVAL, VSCALE_HTTP_TIMEOUT")
|
fmt.Fprintln(w, "\tvscale:\tVSCALE_BASE_URL, VSCALE_TTL, VSCALE_PROPAGATION_TIMEOUT, VSCALE_POLLING_INTERVAL, VSCALE_HTTP_TIMEOUT")
|
||||||
|
fmt.Fprintln(w, "\tvultr:\tVULTR_POLLING_INTERVAL, VULTR_PROPAGATION_TIMEOUT, VULTR_TTL, VULTR_HTTP_TIMEOUT")
|
||||||
|
|
||||||
w.Flush()
|
w.Flush()
|
||||||
|
|
121
cmd/cmd_list.go
Normal file
121
cmd/cmd_list.go
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/urfave/cli"
|
||||||
|
"github.com/xenolf/lego/certcrypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createList() cli.Command {
|
||||||
|
return cli.Command{
|
||||||
|
Name: "list",
|
||||||
|
Usage: "Display certificates and accounts information.",
|
||||||
|
Action: list,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
cli.BoolFlag{
|
||||||
|
Name: "accounts, a",
|
||||||
|
Usage: "Display accounts.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(ctx *cli.Context) error {
|
||||||
|
if ctx.Bool("accounts") {
|
||||||
|
if err := listAccount(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listCertificates(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listCertificates(ctx *cli.Context) error {
|
||||||
|
certsStorage := NewCertificatesStorage(ctx)
|
||||||
|
|
||||||
|
matches, err := filepath.Glob(filepath.Join(certsStorage.GetRootPath(), "*.crt"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
fmt.Println("No certificates found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Found the following certs:")
|
||||||
|
for _, filename := range matches {
|
||||||
|
if strings.HasSuffix(filename, ".issuer.crt") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := ioutil.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
pCert, err := certcrypto.ParsePEMCertificate(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(" Certificate Name:", pCert.Subject.CommonName)
|
||||||
|
fmt.Println(" Domains:", strings.Join(pCert.DNSNames, ", "))
|
||||||
|
fmt.Println(" Expiry Date:", pCert.NotAfter)
|
||||||
|
fmt.Println(" Certificate Path:", filename)
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listAccount(ctx *cli.Context) error {
|
||||||
|
// fake email, needed by NewAccountsStorage
|
||||||
|
if err := ctx.GlobalSet("email", "unknown"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
accountsStorage := NewAccountsStorage(ctx)
|
||||||
|
|
||||||
|
matches, err := filepath.Glob(filepath.Join(accountsStorage.GetRootPath(), "*", "*", "*.json"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
fmt.Println("No accounts found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Found the following accounts:")
|
||||||
|
for _, filename := range matches {
|
||||||
|
data, err := ioutil.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var account Account
|
||||||
|
err = json.Unmarshal(data, &account)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
uri, err := url.Parse(account.Registration.URI)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(" Email:", account.Email)
|
||||||
|
fmt.Println(" Server:", uri.Host)
|
||||||
|
fmt.Println(" Path:", filepath.Dir(filename))
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
192
cmd/cmd_renew.go
Normal file
192
cmd/cmd_renew.go
Normal file
|
@ -0,0 +1,192 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/x509"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/urfave/cli"
|
||||||
|
"github.com/xenolf/lego/certcrypto"
|
||||||
|
"github.com/xenolf/lego/certificate"
|
||||||
|
"github.com/xenolf/lego/lego"
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createRenew() cli.Command {
|
||||||
|
return cli.Command{
|
||||||
|
Name: "renew",
|
||||||
|
Usage: "Renew a certificate",
|
||||||
|
Action: renew,
|
||||||
|
Before: func(ctx *cli.Context) error {
|
||||||
|
// we require either domains or csr, but not both
|
||||||
|
hasDomains := len(ctx.GlobalStringSlice("domains")) > 0
|
||||||
|
hasCsr := len(ctx.GlobalString("csr")) > 0
|
||||||
|
if hasDomains && hasCsr {
|
||||||
|
log.Fatal("Please specify either --domains/-d or --csr/-c, but not both")
|
||||||
|
}
|
||||||
|
if !hasDomains && !hasCsr {
|
||||||
|
log.Fatal("Please specify --domains/-d (or --csr/-c if you already have a CSR)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
cli.IntFlag{
|
||||||
|
Name: "days",
|
||||||
|
Value: 15,
|
||||||
|
Usage: "The number of days left on a certificate to renew it.",
|
||||||
|
},
|
||||||
|
cli.BoolFlag{
|
||||||
|
Name: "reuse-key",
|
||||||
|
Usage: "Used to indicate you want to reuse your current private key for the new certificate.",
|
||||||
|
},
|
||||||
|
cli.BoolFlag{
|
||||||
|
Name: "no-bundle",
|
||||||
|
Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.",
|
||||||
|
},
|
||||||
|
cli.BoolFlag{
|
||||||
|
Name: "must-staple",
|
||||||
|
Usage: "Include the OCSP must staple TLS extension in the CSR and generated certificate. Only works if the CSR is generated by lego.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renew(ctx *cli.Context) error {
|
||||||
|
account, client := setup(ctx, NewAccountsStorage(ctx))
|
||||||
|
|
||||||
|
if account.Registration == nil {
|
||||||
|
log.Fatalf("Account %s is not registered. Use 'run' to register a new account.\n", account.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
certsStorage := NewCertificatesStorage(ctx)
|
||||||
|
|
||||||
|
bundle := !ctx.Bool("no-bundle")
|
||||||
|
|
||||||
|
// CSR
|
||||||
|
if ctx.GlobalIsSet("csr") {
|
||||||
|
return renewForCSR(ctx, client, certsStorage, bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domains
|
||||||
|
return renewForDomains(ctx, client, certsStorage, bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *CertificatesStorage, bundle bool) error {
|
||||||
|
domains := ctx.GlobalStringSlice("domains")
|
||||||
|
domain := domains[0]
|
||||||
|
|
||||||
|
// load the cert resource from files.
|
||||||
|
// We store the certificate, private key and metadata in different files
|
||||||
|
// as web servers would not be able to work with a combined file.
|
||||||
|
certificates, err := certsStorage.ReadCertificate(domain, ".crt")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error while loading the certificate for domain %s\n\t%v", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cert := certificates[0]
|
||||||
|
|
||||||
|
if !needRenewal(cert, domain, ctx.Int("days")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is just meant to be informal for the user.
|
||||||
|
timeLeft := cert.NotAfter.Sub(time.Now().UTC())
|
||||||
|
log.Infof("[%s] acme: Trying renewal with %d hours remaining", domain, int(timeLeft.Hours()))
|
||||||
|
|
||||||
|
certDomains := certcrypto.ExtractDomains(cert)
|
||||||
|
|
||||||
|
var privateKey crypto.PrivateKey
|
||||||
|
if ctx.Bool("reuse-key") {
|
||||||
|
keyBytes, errR := certsStorage.ReadFile(domain, ".key")
|
||||||
|
if errR != nil {
|
||||||
|
log.Fatalf("Error while loading the private key for domain %s\n\t%v", domain, errR)
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, errR = certcrypto.ParsePEMPrivateKey(keyBytes)
|
||||||
|
if errR != nil {
|
||||||
|
return errR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request := certificate.ObtainRequest{
|
||||||
|
Domains: merge(certDomains, domains),
|
||||||
|
Bundle: bundle,
|
||||||
|
PrivateKey: privateKey,
|
||||||
|
MustStaple: ctx.Bool("must-staple"),
|
||||||
|
}
|
||||||
|
certRes, err := client.Certificate.Obtain(request)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certsStorage.SaveResource(certRes)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *CertificatesStorage, bundle bool) error {
|
||||||
|
csr, err := readCSRFile(ctx.GlobalString("csr"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := csr.Subject.CommonName
|
||||||
|
|
||||||
|
// load the cert resource from files.
|
||||||
|
// We store the certificate, private key and metadata in different files
|
||||||
|
// as web servers would not be able to work with a combined file.
|
||||||
|
certificates, err := certsStorage.ReadCertificate(domain, ".crt")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error while loading the certificate for domain %s\n\t%v", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cert := certificates[0]
|
||||||
|
|
||||||
|
if !needRenewal(cert, domain, ctx.Int("days")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is just meant to be informal for the user.
|
||||||
|
timeLeft := cert.NotAfter.Sub(time.Now().UTC())
|
||||||
|
log.Infof("[%s] acme: Trying renewal with %d hours remaining", domain, int(timeLeft.Hours()))
|
||||||
|
|
||||||
|
certRes, err := client.Certificate.ObtainForCSR(*csr, bundle)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certsStorage.SaveResource(certRes)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func needRenewal(x509Cert *x509.Certificate, domain string, days int) bool {
|
||||||
|
if x509Cert.IsCA {
|
||||||
|
log.Fatalf("[%s] Certificate bundle starts with a CA certificate", domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if days >= 0 {
|
||||||
|
if int(time.Until(x509Cert.NotAfter).Hours()/24.0) > days {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func merge(prevDomains []string, nextDomains []string) []string {
|
||||||
|
for _, next := range nextDomains {
|
||||||
|
var found bool
|
||||||
|
for _, prev := range prevDomains {
|
||||||
|
if prev == next {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
prevDomains = append(prevDomains, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prevDomains
|
||||||
|
}
|
57
cmd/cmd_renew_test.go
Normal file
57
cmd/cmd_renew_test.go
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_merge(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
desc string
|
||||||
|
prevDomains []string
|
||||||
|
nextDomains []string
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "all empty",
|
||||||
|
prevDomains: []string{},
|
||||||
|
nextDomains: []string{},
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "next empty",
|
||||||
|
prevDomains: []string{"a", "b", "c"},
|
||||||
|
nextDomains: []string{},
|
||||||
|
expected: []string{"a", "b", "c"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "prev empty",
|
||||||
|
prevDomains: []string{},
|
||||||
|
nextDomains: []string{"a", "b", "c"},
|
||||||
|
expected: []string{"a", "b", "c"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "merge append",
|
||||||
|
prevDomains: []string{"a", "b", "c"},
|
||||||
|
nextDomains: []string{"a", "c", "d"},
|
||||||
|
expected: []string{"a", "b", "c", "d"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "merge same",
|
||||||
|
prevDomains: []string{"a", "b", "c"},
|
||||||
|
nextDomains: []string{"a", "b", "c"},
|
||||||
|
expected: []string{"a", "b", "c"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range testCases {
|
||||||
|
test := test
|
||||||
|
t.Run(test.desc, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
actual := merge(test.prevDomains, test.nextDomains)
|
||||||
|
assert.Equal(t, test.expected, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
62
cmd/cmd_revoke.go
Normal file
62
cmd/cmd_revoke.go
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/urfave/cli"
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createRevoke() cli.Command {
|
||||||
|
return cli.Command{
|
||||||
|
Name: "revoke",
|
||||||
|
Usage: "Revoke a certificate",
|
||||||
|
Action: revoke,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
cli.BoolFlag{
|
||||||
|
Name: "keep, k",
|
||||||
|
Usage: "Keep the certificates after the revocation instead of archiving them.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func revoke(ctx *cli.Context) error {
|
||||||
|
acc, client := setup(ctx, NewAccountsStorage(ctx))
|
||||||
|
|
||||||
|
if acc.Registration == nil {
|
||||||
|
log.Fatalf("Account %s is not registered. Use 'run' to register a new account.\n", acc.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
certsStorage := NewCertificatesStorage(ctx)
|
||||||
|
certsStorage.CreateRootFolder()
|
||||||
|
|
||||||
|
for _, domain := range ctx.GlobalStringSlice("domains") {
|
||||||
|
log.Printf("Trying to revoke certificate for domain %s", domain)
|
||||||
|
|
||||||
|
certBytes, err := certsStorage.ReadFile(domain, ".crt")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error while revoking the certificate for domain %s\n\t%v", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.Certificate.Revoke(certBytes)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error while revoking the certificate for domain %s\n\t%v", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Certificate was revoked.")
|
||||||
|
|
||||||
|
if ctx.Bool("keep") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
certsStorage.CreateArchiveFolder()
|
||||||
|
|
||||||
|
err = certsStorage.MoveToArchive(domain)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Certificate was archived for domain:", domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
162
cmd/cmd_run.go
Normal file
162
cmd/cmd_run.go
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/urfave/cli"
|
||||||
|
"github.com/xenolf/lego/certificate"
|
||||||
|
"github.com/xenolf/lego/lego"
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
"github.com/xenolf/lego/registration"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createRun() cli.Command {
|
||||||
|
return cli.Command{
|
||||||
|
Name: "run",
|
||||||
|
Usage: "Register an account, then create and install a certificate",
|
||||||
|
Before: func(ctx *cli.Context) error {
|
||||||
|
// we require either domains or csr, but not both
|
||||||
|
hasDomains := len(ctx.GlobalStringSlice("domains")) > 0
|
||||||
|
hasCsr := len(ctx.GlobalString("csr")) > 0
|
||||||
|
if hasDomains && hasCsr {
|
||||||
|
log.Fatal("Please specify either --domains/-d or --csr/-c, but not both")
|
||||||
|
}
|
||||||
|
if !hasDomains && !hasCsr {
|
||||||
|
log.Fatal("Please specify --domains/-d (or --csr/-c if you already have a CSR)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Action: run,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
cli.BoolFlag{
|
||||||
|
Name: "no-bundle",
|
||||||
|
Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.",
|
||||||
|
},
|
||||||
|
cli.BoolFlag{
|
||||||
|
Name: "must-staple",
|
||||||
|
Usage: "Include the OCSP must staple TLS extension in the CSR and generated certificate. Only works if the CSR is generated by lego.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx *cli.Context) error {
|
||||||
|
accountsStorage := NewAccountsStorage(ctx)
|
||||||
|
|
||||||
|
account, client := setup(ctx, accountsStorage)
|
||||||
|
|
||||||
|
if account.Registration == nil {
|
||||||
|
reg, err := register(ctx, client)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not complete registration\n\t%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
account.Registration = reg
|
||||||
|
|
||||||
|
if err = accountsStorage.Save(account); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("!!!! HEADS UP !!!!")
|
||||||
|
fmt.Printf(`
|
||||||
|
Your account credentials have been saved in your Let's Encrypt
|
||||||
|
configuration directory at "%s".
|
||||||
|
You should make a secure backup of this folder now. This
|
||||||
|
configuration directory will also contain certificates and
|
||||||
|
private keys obtained from Let's Encrypt so making regular
|
||||||
|
backups of this folder is ideal.`, accountsStorage.GetRootPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
certsStorage := NewCertificatesStorage(ctx)
|
||||||
|
certsStorage.CreateRootFolder()
|
||||||
|
|
||||||
|
cert, err := obtainCertificate(ctx, client)
|
||||||
|
if err != nil {
|
||||||
|
// Make sure to return a non-zero exit code if ObtainSANCertificate returned at least one error.
|
||||||
|
// Due to us not returning partial certificate we can just exit here instead of at the end.
|
||||||
|
log.Fatalf("Could not obtain certificates:\n\t%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certsStorage.SaveResource(cert)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleTOS(ctx *cli.Context, client *lego.Client) bool {
|
||||||
|
// Check for a global accept override
|
||||||
|
if ctx.GlobalBool("accept-tos") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
log.Printf("Please review the TOS at %s", client.GetToSURL())
|
||||||
|
|
||||||
|
for {
|
||||||
|
fmt.Println("Do you accept the TOS? Y/n")
|
||||||
|
text, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not read from console: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text = strings.Trim(text, "\r\n")
|
||||||
|
switch text {
|
||||||
|
case "", "y", "Y":
|
||||||
|
return true
|
||||||
|
case "n", "N":
|
||||||
|
log.Fatal("You did not accept the TOS. Unable to proceed.")
|
||||||
|
default:
|
||||||
|
fmt.Println("Your input was invalid. Please answer with one of Y/y, n/N or by pressing enter.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func register(ctx *cli.Context, client *lego.Client) (*registration.Resource, error) {
|
||||||
|
accepted := handleTOS(ctx, client)
|
||||||
|
if !accepted {
|
||||||
|
log.Fatal("You did not accept the TOS. Unable to proceed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.GlobalBool("eab") {
|
||||||
|
kid := ctx.GlobalString("kid")
|
||||||
|
hmacEncoded := ctx.GlobalString("hmac")
|
||||||
|
|
||||||
|
if kid == "" || hmacEncoded == "" {
|
||||||
|
log.Fatalf("Requires arguments --kid and --hmac.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
|
||||||
|
TermsOfServiceAgreed: accepted,
|
||||||
|
Kid: kid,
|
||||||
|
HmacEncoded: hmacEncoded,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func obtainCertificate(ctx *cli.Context, client *lego.Client) (*certificate.Resource, error) {
|
||||||
|
bundle := !ctx.Bool("no-bundle")
|
||||||
|
|
||||||
|
domains := ctx.GlobalStringSlice("domains")
|
||||||
|
if len(domains) > 0 {
|
||||||
|
// obtain a certificate, generating a new private key
|
||||||
|
request := certificate.ObtainRequest{
|
||||||
|
Domains: domains,
|
||||||
|
Bundle: bundle,
|
||||||
|
MustStaple: ctx.Bool("must-staple"),
|
||||||
|
}
|
||||||
|
return client.Certificate.Obtain(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// read the CSR
|
||||||
|
csr, err := readCSRFile(ctx.GlobalString("csr"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// obtain a certificate for this CSR
|
||||||
|
return client.Certificate.ObtainForCSR(*csr, bundle)
|
||||||
|
}
|
102
cmd/flags.go
Normal file
102
cmd/flags.go
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/urfave/cli"
|
||||||
|
"github.com/xenolf/lego/lego"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateFlags(defaultPath string) []cli.Flag {
|
||||||
|
return []cli.Flag{
|
||||||
|
cli.StringSliceFlag{
|
||||||
|
Name: "domains, d",
|
||||||
|
Usage: "Add a domain to the process. Can be specified multiple times.",
|
||||||
|
},
|
||||||
|
cli.StringFlag{
|
||||||
|
Name: "server, s",
|
||||||
|
Value: lego.LEDirectoryProduction,
|
||||||
|
Usage: "CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client.",
|
||||||
|
},
|
||||||
|
cli.BoolFlag{
|
||||||
|
Name: "accept-tos, a",
|
||||||
|
Usage: "By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service.",
|
||||||
|
},
|
||||||
|
cli.StringFlag{
|
||||||
|
Name: "email, m",
|
||||||
|
Usage: "Email used for registration and recovery contact.",
|
||||||
|
},
|
||||||
|
cli.StringFlag{
|
||||||
|
Name: "csr, c",
|
||||||
|
Usage: "Certificate signing request filename, if an external CSR is to be used",
|
||||||
|
},
|
||||||
|
cli.BoolFlag{
|
||||||
|
Name: "eab",
|
||||||
|
Usage: "Use External Account Binding for account registration. Requires --kid and --hmac.",
|
||||||
|
},
|
||||||
|
cli.StringFlag{
|
||||||
|
Name: "kid",
|
||||||
|
Usage: "Key identifier from External CA. Used for External Account Binding.",
|
||||||
|
},
|
||||||
|
cli.StringFlag{
|
||||||
|
Name: "hmac",
|
||||||
|
Usage: "MAC key from External CA. Should be in Base64 URL Encoding without padding format. Used for External Account Binding.",
|
||||||
|
},
|
||||||
|
cli.StringFlag{
|
||||||
|
Name: "key-type, k",
|
||||||
|
Value: "rsa2048",
|
||||||
|
Usage: "Key type to use for private keys. Supported: rsa2048, rsa4096, rsa8192, ec256, ec384",
|
||||||
|
},
|
||||||
|
cli.StringFlag{
|
||||||
|
Name: "filename",
|
||||||
|
Usage: "Filename of the generated certificate",
|
||||||
|
},
|
||||||
|
cli.StringFlag{
|
||||||
|
Name: "path",
|
||||||
|
Usage: "Directory to use for storing the data",
|
||||||
|
Value: defaultPath,
|
||||||
|
},
|
||||||
|
cli.StringSliceFlag{
|
||||||
|
Name: "exclude, x",
|
||||||
|
Usage: "Explicitly disallow solvers by name from being used. Solvers: \"http-01\", \"dns-01\", \"tls-alpn-01\".",
|
||||||
|
},
|
||||||
|
cli.IntFlag{
|
||||||
|
Name: "http-timeout",
|
||||||
|
Usage: "Set the HTTP timeout value to a specific value in seconds. The default is 10 seconds.",
|
||||||
|
},
|
||||||
|
cli.StringFlag{
|
||||||
|
Name: "webroot",
|
||||||
|
Usage: "Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge",
|
||||||
|
},
|
||||||
|
cli.StringSliceFlag{
|
||||||
|
Name: "memcached-host",
|
||||||
|
Usage: "Set the memcached host(s) to use for HTTP based challenges. Challenges will be written to all specified hosts.",
|
||||||
|
},
|
||||||
|
cli.StringFlag{
|
||||||
|
Name: "http",
|
||||||
|
Usage: "Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port",
|
||||||
|
},
|
||||||
|
cli.StringFlag{
|
||||||
|
Name: "tls",
|
||||||
|
Usage: "Set the port and interface to use for TLS based challenges to listen on. Supported: interface:port or :port",
|
||||||
|
},
|
||||||
|
cli.StringFlag{
|
||||||
|
Name: "dns",
|
||||||
|
Usage: "Solve a DNS challenge using the specified provider. Disables all other challenges. Run 'lego dnshelp' for help on usage.",
|
||||||
|
},
|
||||||
|
cli.BoolFlag{
|
||||||
|
Name: "dns-disable-cp",
|
||||||
|
Usage: "By setting this flag to true, disables the need to wait the propagation of the TXT record to all authoritative name servers.",
|
||||||
|
},
|
||||||
|
cli.StringSliceFlag{
|
||||||
|
Name: "dns-resolvers",
|
||||||
|
Usage: "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.",
|
||||||
|
},
|
||||||
|
cli.IntFlag{
|
||||||
|
Name: "dns-timeout",
|
||||||
|
Usage: "Set the DNS timeout value to a specific value in seconds. Used only when performing authoritative name servers queries. The default is 10 seconds.",
|
||||||
|
},
|
||||||
|
cli.BoolFlag{
|
||||||
|
Name: "pem",
|
||||||
|
Usage: "Generate a .pem file by concatenating the .key and .crt files together.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
40
cmd/lego/main.go
Normal file
40
cmd/lego/main.go
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
// Let's Encrypt client to go!
|
||||||
|
// CLI application for generating Let's Encrypt certificates using the ACME package.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/urfave/cli"
|
||||||
|
"github.com/xenolf/lego/cmd"
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
version = "dev"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := cli.NewApp()
|
||||||
|
app.Name = "lego"
|
||||||
|
app.HelpName = "lego"
|
||||||
|
app.Usage = "Let's Encrypt client written in Go"
|
||||||
|
app.Version = version
|
||||||
|
|
||||||
|
defaultPath := ""
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err == nil {
|
||||||
|
defaultPath = filepath.Join(cwd, ".lego")
|
||||||
|
}
|
||||||
|
app.Flags = cmd.CreateFlags(defaultPath)
|
||||||
|
|
||||||
|
app.Before = cmd.Before
|
||||||
|
|
||||||
|
app.Commands = cmd.CreateCommands()
|
||||||
|
|
||||||
|
err = app.Run(os.Args)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
128
cmd/setup.go
Normal file
128
cmd/setup.go
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/urfave/cli"
|
||||||
|
"github.com/xenolf/lego/certcrypto"
|
||||||
|
"github.com/xenolf/lego/lego"
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
"github.com/xenolf/lego/registration"
|
||||||
|
)
|
||||||
|
|
||||||
|
const filePerm os.FileMode = 0600
|
||||||
|
|
||||||
|
func setup(ctx *cli.Context, accountsStorage *AccountsStorage) (*Account, *lego.Client) {
|
||||||
|
privateKey := accountsStorage.GetPrivateKey()
|
||||||
|
|
||||||
|
var account *Account
|
||||||
|
if accountsStorage.ExistsAccountFilePath() {
|
||||||
|
account = accountsStorage.LoadAccount(privateKey)
|
||||||
|
} else {
|
||||||
|
account = &Account{Email: accountsStorage.GetUserID(), key: privateKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := newClient(ctx, account)
|
||||||
|
|
||||||
|
return account, client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClient(ctx *cli.Context, acc registration.User) *lego.Client {
|
||||||
|
keyType := getKeyType(ctx)
|
||||||
|
|
||||||
|
config := lego.NewConfig(acc)
|
||||||
|
config.CADirURL = ctx.GlobalString("server")
|
||||||
|
config.KeyType = keyType
|
||||||
|
config.UserAgent = fmt.Sprintf("lego-cli/%s", ctx.App.Version)
|
||||||
|
|
||||||
|
if ctx.GlobalIsSet("http-timeout") {
|
||||||
|
config.HTTPClient.Timeout = time.Duration(ctx.GlobalInt("http-timeout")) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := lego.NewClient(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Could not create client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
setupChallenges(ctx, client)
|
||||||
|
|
||||||
|
if client.GetExternalAccountRequired() && !ctx.GlobalIsSet("eab") {
|
||||||
|
log.Fatal("Server requires External Account Binding. Use --eab with --kid and --hmac.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
// getKeyType the type from which private keys should be generated
|
||||||
|
func getKeyType(ctx *cli.Context) certcrypto.KeyType {
|
||||||
|
keyType := ctx.GlobalString("key-type")
|
||||||
|
switch strings.ToUpper(keyType) {
|
||||||
|
case "RSA2048":
|
||||||
|
return certcrypto.RSA2048
|
||||||
|
case "RSA4096":
|
||||||
|
return certcrypto.RSA4096
|
||||||
|
case "RSA8192":
|
||||||
|
return certcrypto.RSA8192
|
||||||
|
case "EC256":
|
||||||
|
return certcrypto.EC256
|
||||||
|
case "EC384":
|
||||||
|
return certcrypto.EC384
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Fatalf("Unsupported KeyType: %s", keyType)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEmail(ctx *cli.Context) string {
|
||||||
|
email := ctx.GlobalString("email")
|
||||||
|
if len(email) == 0 {
|
||||||
|
log.Fatal("You have to pass an account (email address) to the program using --email or -m")
|
||||||
|
}
|
||||||
|
return email
|
||||||
|
}
|
||||||
|
|
||||||
|
func createNonExistingFolder(path string) error {
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
return os.MkdirAll(path, 0700)
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCSRFile(filename string) (*x509.CertificateRequest, error) {
|
||||||
|
bytes, err := ioutil.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw := bytes
|
||||||
|
|
||||||
|
// see if we can find a PEM-encoded CSR
|
||||||
|
var p *pem.Block
|
||||||
|
rest := bytes
|
||||||
|
for {
|
||||||
|
// decode a PEM block
|
||||||
|
p, rest = pem.Decode(rest)
|
||||||
|
|
||||||
|
// did we fail?
|
||||||
|
if p == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// did we get a CSR?
|
||||||
|
if p.Type == "CERTIFICATE REQUEST" {
|
||||||
|
raw = p.Bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// no PEM-encoded CSR
|
||||||
|
// assume we were given a DER-encoded ASN.1 CSR
|
||||||
|
// (if this assumption is wrong, parsing these bytes will fail)
|
||||||
|
return x509.ParseCertificateRequest(raw)
|
||||||
|
}
|
123
cmd/setup_challenges.go
Normal file
123
cmd/setup_challenges.go
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/urfave/cli"
|
||||||
|
"github.com/xenolf/lego/challenge"
|
||||||
|
"github.com/xenolf/lego/challenge/dns01"
|
||||||
|
"github.com/xenolf/lego/lego"
|
||||||
|
"github.com/xenolf/lego/log"
|
||||||
|
"github.com/xenolf/lego/providers/dns"
|
||||||
|
"github.com/xenolf/lego/providers/http/memcached"
|
||||||
|
"github.com/xenolf/lego/providers/http/webroot"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupChallenges(ctx *cli.Context, client *lego.Client) {
|
||||||
|
if len(ctx.GlobalStringSlice("exclude")) > 0 {
|
||||||
|
excludedSolvers(ctx, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.GlobalIsSet("webroot") {
|
||||||
|
setupWebroot(client, ctx.GlobalString("webroot"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.GlobalIsSet("memcached-host") {
|
||||||
|
setupMemcached(client, ctx.GlobalStringSlice("memcached-host"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.GlobalIsSet("http") {
|
||||||
|
setupHTTP(client, ctx.GlobalString("http"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.GlobalIsSet("tls") {
|
||||||
|
setupTLS(client, ctx.GlobalString("tls"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.GlobalIsSet("dns") {
|
||||||
|
setupDNS(ctx, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func excludedSolvers(ctx *cli.Context, client *lego.Client) {
|
||||||
|
var cc []challenge.Type
|
||||||
|
for _, s := range ctx.GlobalStringSlice("exclude") {
|
||||||
|
cc = append(cc, challenge.Type(s))
|
||||||
|
}
|
||||||
|
client.Challenge.Exclude(cc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupWebroot(client *lego.Client, path string) {
|
||||||
|
provider, err := webroot.NewHTTPProvider(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.Challenge.SetHTTP01Provider(provider)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --webroot=foo indicates that the user specifically want to do a HTTP challenge
|
||||||
|
// infer that the user also wants to exclude all other challenges
|
||||||
|
client.Challenge.Exclude([]challenge.Type{challenge.DNS01, challenge.TLSALPN01})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupMemcached(client *lego.Client, hosts []string) {
|
||||||
|
provider, err := memcached.NewMemcachedProvider(hosts)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.Challenge.SetHTTP01Provider(provider)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --memcached-host=foo:11211 indicates that the user specifically want to do a HTTP challenge
|
||||||
|
// infer that the user also wants to exclude all other challenges
|
||||||
|
client.Challenge.Exclude([]challenge.Type{challenge.DNS01, challenge.TLSALPN01})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupHTTP(client *lego.Client, iface string) {
|
||||||
|
if !strings.Contains(iface, ":") {
|
||||||
|
log.Fatalf("The --http switch only accepts interface:port or :port for its argument.")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := client.Challenge.SetHTTP01Address(iface)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTLS(client *lego.Client, iface string) {
|
||||||
|
if !strings.Contains(iface, ":") {
|
||||||
|
log.Fatalf("The --tls switch only accepts interface:port or :port for its argument.")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := client.Challenge.SetTLSALPN01Address(iface)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupDNS(ctx *cli.Context, client *lego.Client) {
|
||||||
|
provider, err := dns.NewDNSChallengeProviderByName(ctx.GlobalString("dns"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
servers := ctx.GlobalStringSlice("dns-resolvers")
|
||||||
|
err = client.Challenge.SetDNS01Provider(provider,
|
||||||
|
dns01.CondOption(len(servers) > 0,
|
||||||
|
dns01.AddRecursiveNameservers(dns01.ParseNameservers(ctx.GlobalStringSlice("dns-resolvers")))),
|
||||||
|
dns01.CondOption(ctx.GlobalIsSet("dns-disable-cp"),
|
||||||
|
dns01.DisableCompletePropagationRequirement()),
|
||||||
|
dns01.CondOption(ctx.GlobalIsSet("dns-timeout"),
|
||||||
|
dns01.AddDNSTimeout(time.Duration(ctx.GlobalInt("dns-timeout"))*time.Second)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,75 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/urfave/cli"
|
|
||||||
"github.com/xenolf/lego/acme"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Configuration type from CLI and config files.
|
|
||||||
type Configuration struct {
|
|
||||||
context *cli.Context
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewConfiguration creates a new configuration from CLI data.
|
|
||||||
func NewConfiguration(c *cli.Context) *Configuration {
|
|
||||||
return &Configuration{context: c}
|
|
||||||
}
|
|
||||||
|
|
||||||
// KeyType the type from which private keys should be generated
|
|
||||||
func (c *Configuration) KeyType() (acme.KeyType, error) {
|
|
||||||
switch strings.ToUpper(c.context.GlobalString("key-type")) {
|
|
||||||
case "RSA2048":
|
|
||||||
return acme.RSA2048, nil
|
|
||||||
case "RSA4096":
|
|
||||||
return acme.RSA4096, nil
|
|
||||||
case "RSA8192":
|
|
||||||
return acme.RSA8192, nil
|
|
||||||
case "EC256":
|
|
||||||
return acme.EC256, nil
|
|
||||||
case "EC384":
|
|
||||||
return acme.EC384, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("Unsupported KeyType: %s", c.context.GlobalString("key-type"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExcludedSolvers is a list of solvers that are to be excluded.
|
|
||||||
func (c *Configuration) ExcludedSolvers() (cc []acme.Challenge) {
|
|
||||||
for _, s := range c.context.GlobalStringSlice("exclude") {
|
|
||||||
cc = append(cc, acme.Challenge(s))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServerPath returns the OS dependent path to the data for a specific CA
|
|
||||||
func (c *Configuration) ServerPath() string {
|
|
||||||
srv, _ := url.Parse(c.context.GlobalString("server"))
|
|
||||||
return strings.NewReplacer(":", "_", "/", string(os.PathSeparator)).Replace(srv.Host)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CertPath gets the path for certificates.
|
|
||||||
func (c *Configuration) CertPath() string {
|
|
||||||
return filepath.Join(c.context.GlobalString("path"), "certificates")
|
|
||||||
}
|
|
||||||
|
|
||||||
// AccountsPath returns the OS dependent path to the
|
|
||||||
// local accounts for a specific CA
|
|
||||||
func (c *Configuration) AccountsPath() string {
|
|
||||||
return filepath.Join(c.context.GlobalString("path"), "accounts", c.ServerPath())
|
|
||||||
}
|
|
||||||
|
|
||||||
// AccountPath returns the OS dependent path to a particular account
|
|
||||||
func (c *Configuration) AccountPath(acc string) string {
|
|
||||||
return filepath.Join(c.AccountsPath(), acc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AccountKeysPath returns the OS dependent path to the keys of a particular account
|
|
||||||
func (c *Configuration) AccountKeysPath(acc string) string {
|
|
||||||
return filepath.Join(c.AccountPath(acc), "keys")
|
|
||||||
}
|
|
58
crypto.go
58
crypto.go
|
@ -1,58 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto"
|
|
||||||
"crypto/ecdsa"
|
|
||||||
"crypto/elliptic"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/pem"
|
|
||||||
"errors"
|
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func generatePrivateKey(file string) (crypto.PrivateKey, error) {
|
|
||||||
privateKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
keyBytes, err := x509.MarshalECPrivateKey(privateKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
pemKey := pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}
|
|
||||||
|
|
||||||
certOut, err := os.Create(file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer certOut.Close()
|
|
||||||
|
|
||||||
err = pem.Encode(certOut, &pemKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return privateKey, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadPrivateKey(file string) (crypto.PrivateKey, error) {
|
|
||||||
keyBytes, err := ioutil.ReadFile(file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
keyBlock, _ := pem.Decode(keyBytes)
|
|
||||||
|
|
||||||
switch keyBlock.Type {
|
|
||||||
case "RSA PRIVATE KEY":
|
|
||||||
return x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
|
|
||||||
case "EC PRIVATE KEY":
|
|
||||||
return x509.ParseECPrivateKey(keyBlock.Bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, errors.New("unknown private key type")
|
|
||||||
}
|
|
344
e2e/challenges_test.go
Normal file
344
e2e/challenges_test.go
Normal file
|
@ -0,0 +1,344 @@
|
||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/xenolf/lego/certificate"
|
||||||
|
"github.com/xenolf/lego/challenge"
|
||||||
|
"github.com/xenolf/lego/e2e/loader"
|
||||||
|
"github.com/xenolf/lego/lego"
|
||||||
|
"github.com/xenolf/lego/registration"
|
||||||
|
)
|
||||||
|
|
||||||
|
var load = loader.EnvLoader{
|
||||||
|
PebbleOptions: &loader.CmdOption{
|
||||||
|
HealthCheckURL: "https://localhost:14000/dir",
|
||||||
|
Args: []string{"-strict", "-config", "fixtures/pebble-config.json"},
|
||||||
|
Env: []string{"PEBBLE_VA_NOSLEEP=1", "PEBBLE_WFE_NONCEREJECT=20"},
|
||||||
|
},
|
||||||
|
LegoOptions: []string{
|
||||||
|
"LEGO_CA_CERTIFICATES=./fixtures/certs/pebble.minica.pem",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
os.Exit(load.MainTest(m))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelp(t *testing.T) {
|
||||||
|
output, err := load.RunLego("-h")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", output)
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stdout, "%s\n", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChallengeHTTP_Run(t *testing.T) {
|
||||||
|
loader.CleanLegoFiles()
|
||||||
|
|
||||||
|
output, err := load.RunLego(
|
||||||
|
"-m", "hubert@hubert.com",
|
||||||
|
"--accept-tos",
|
||||||
|
"-x", "dns-01",
|
||||||
|
"-x", "tls-alpn-01",
|
||||||
|
"-s", "https://localhost:14000/dir",
|
||||||
|
"-d", "acme.wtf",
|
||||||
|
"--http", ":5002",
|
||||||
|
"--tls", ":5001",
|
||||||
|
"run")
|
||||||
|
|
||||||
|
if len(output) > 0 {
|
||||||
|
fmt.Fprintf(os.Stdout, "%s\n", output)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChallengeTLS_Run_Domains(t *testing.T) {
|
||||||
|
loader.CleanLegoFiles()
|
||||||
|
|
||||||
|
output, err := load.RunLego(
|
||||||
|
"-m", "hubert@hubert.com",
|
||||||
|
"--accept-tos",
|
||||||
|
"-x", "dns-01",
|
||||||
|
"-x", "http-01",
|
||||||
|
"-s", "https://localhost:14000/dir",
|
||||||
|
"-d", "acme.wtf",
|
||||||
|
"--http", ":5002",
|
||||||
|
"--tls", ":5001",
|
||||||
|
"run")
|
||||||
|
|
||||||
|
if len(output) > 0 {
|
||||||
|
fmt.Fprintf(os.Stdout, "%s\n", output)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChallengeTLS_Run_CSR(t *testing.T) {
|
||||||
|
loader.CleanLegoFiles()
|
||||||
|
|
||||||
|
output, err := load.RunLego(
|
||||||
|
"-m", "hubert@hubert.com",
|
||||||
|
"--accept-tos",
|
||||||
|
"-x", "dns-01",
|
||||||
|
"-x", "http-01",
|
||||||
|
"-s", "https://localhost:14000/dir",
|
||||||
|
"-csr", "./fixtures/csr.raw",
|
||||||
|
"--http", ":5002",
|
||||||
|
"--tls", ":5001",
|
||||||
|
"run")
|
||||||
|
|
||||||
|
if len(output) > 0 {
|
||||||
|
fmt.Fprintf(os.Stdout, "%s\n", output)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChallengeTLS_Run_CSR_PEM(t *testing.T) {
|
||||||
|
loader.CleanLegoFiles()
|
||||||
|
|
||||||
|
output, err := load.RunLego(
|
||||||
|
"-m", "hubert@hubert.com",
|
||||||
|
"--accept-tos",
|
||||||
|
"-x", "dns-01",
|
||||||
|
"-x", "http-01",
|
||||||
|
"-s", "https://localhost:14000/dir",
|
||||||
|
"-csr", "./fixtures/csr.cert",
|
||||||
|
"--http", ":5002",
|
||||||
|
"--tls", ":5001",
|
||||||
|
"run")
|
||||||
|
|
||||||
|
if len(output) > 0 {
|
||||||
|
fmt.Fprintf(os.Stdout, "%s\n", output)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChallengeTLS_Run_Revoke(t *testing.T) {
|
||||||
|
loader.CleanLegoFiles()
|
||||||
|
|
||||||
|
output, err := load.RunLego(
|
||||||
|
"-m", "hubert@hubert.com",
|
||||||
|
"--accept-tos",
|
||||||
|
"-x", "dns-01",
|
||||||
|
"-x", "http-01",
|
||||||
|
"-s", "https://localhost:14000/dir",
|
||||||
|
"-d", "lego.wtf",
|
||||||
|
"-d", "acme.lego.wtf",
|
||||||
|
"--http", ":5002",
|
||||||
|
"--tls", ":5001",
|
||||||
|
"run")
|
||||||
|
|
||||||
|
if len(output) > 0 {
|
||||||
|
fmt.Fprintf(os.Stdout, "%s\n", output)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err = load.RunLego(
|
||||||
|
"-m", "hubert@hubert.com",
|
||||||
|
"--accept-tos",
|
||||||
|
"-x", "dns-01",
|
||||||
|
"-x", "http-01",
|
||||||
|
"-s", "https://localhost:14000/dir",
|
||||||
|
"-d", "lego.wtf",
|
||||||
|
"--http", ":5002",
|
||||||
|
"--tls", ":5001",
|
||||||
|
"revoke")
|
||||||
|
|
||||||
|
if len(output) > 0 {
|
||||||
|
fmt.Fprintf(os.Stdout, "%s\n", output)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChallengeTLS_Run_Revoke_Non_ASCII(t *testing.T) {
|
||||||
|
loader.CleanLegoFiles()
|
||||||
|
|
||||||
|
output, err := load.RunLego(
|
||||||
|
"-m", "hubert@hubert.com",
|
||||||
|
"--accept-tos",
|
||||||
|
"-x", "dns-01",
|
||||||
|
"-x", "http-01",
|
||||||
|
"-s", "https://localhost:14000/dir",
|
||||||
|
"-d", "légô.wtf",
|
||||||
|
"--http", ":5002",
|
||||||
|
"--tls", ":5001",
|
||||||
|
"run")
|
||||||
|
|
||||||
|
if len(output) > 0 {
|
||||||
|
fmt.Fprintf(os.Stdout, "%s\n", output)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err = load.RunLego(
|
||||||
|
"-m", "hubert@hubert.com",
|
||||||
|
"--accept-tos",
|
||||||
|
"-x", "dns-01",
|
||||||
|
"-x", "http-01",
|
||||||
|
"-s", "https://localhost:14000/dir",
|
||||||
|
"-d", "légô.wtf",
|
||||||
|
"--http", ":5002",
|
||||||
|
"--tls", ":5001",
|
||||||
|
"revoke")
|
||||||
|
|
||||||
|
if len(output) > 0 {
|
||||||
|
fmt.Fprintf(os.Stdout, "%s\n", output)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChallengeHTTP_Client_Obtain(t *testing.T) {
|
||||||
|
err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
|
||||||
|
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err, "Could not generate test key")
|
||||||
|
|
||||||
|
user := &fakeUser{privateKey: privateKey}
|
||||||
|
config := lego.NewConfig(user)
|
||||||
|
config.CADirURL = load.PebbleOptions.HealthCheckURL
|
||||||
|
|
||||||
|
client, err := lego.NewClient(config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client.Challenge.Exclude([]challenge.Type{challenge.DNS01, challenge.TLSALPN01})
|
||||||
|
err = client.Challenge.SetHTTP01Address(":5002")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
user.registration = reg
|
||||||
|
|
||||||
|
request := certificate.ObtainRequest{
|
||||||
|
Domains: []string{"acme.wtf"},
|
||||||
|
Bundle: true,
|
||||||
|
}
|
||||||
|
resource, err := client.Certificate.Obtain(request)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NotNil(t, resource)
|
||||||
|
assert.Equal(t, "acme.wtf", resource.Domain)
|
||||||
|
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL)
|
||||||
|
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL)
|
||||||
|
assert.NotEmpty(t, resource.Certificate)
|
||||||
|
assert.NotEmpty(t, resource.IssuerCertificate)
|
||||||
|
assert.Empty(t, resource.CSR)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChallengeTLS_Client_Obtain(t *testing.T) {
|
||||||
|
err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
|
||||||
|
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err, "Could not generate test key")
|
||||||
|
|
||||||
|
user := &fakeUser{privateKey: privateKey}
|
||||||
|
config := lego.NewConfig(user)
|
||||||
|
config.CADirURL = load.PebbleOptions.HealthCheckURL
|
||||||
|
|
||||||
|
client, err := lego.NewClient(config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client.Challenge.Exclude([]challenge.Type{challenge.DNS01, challenge.HTTP01})
|
||||||
|
err = client.Challenge.SetTLSALPN01Address(":5001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
user.registration = reg
|
||||||
|
|
||||||
|
request := certificate.ObtainRequest{
|
||||||
|
Domains: []string{"acme.wtf"},
|
||||||
|
Bundle: true,
|
||||||
|
PrivateKey: privateKey,
|
||||||
|
}
|
||||||
|
resource, err := client.Certificate.Obtain(request)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NotNil(t, resource)
|
||||||
|
assert.Equal(t, "acme.wtf", resource.Domain)
|
||||||
|
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL)
|
||||||
|
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL)
|
||||||
|
assert.NotEmpty(t, resource.Certificate)
|
||||||
|
assert.NotEmpty(t, resource.IssuerCertificate)
|
||||||
|
assert.Empty(t, resource.CSR)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChallengeTLS_Client_ObtainForCSR(t *testing.T) {
|
||||||
|
err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
|
||||||
|
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err, "Could not generate test key")
|
||||||
|
|
||||||
|
user := &fakeUser{privateKey: privateKey}
|
||||||
|
config := lego.NewConfig(user)
|
||||||
|
config.CADirURL = load.PebbleOptions.HealthCheckURL
|
||||||
|
|
||||||
|
client, err := lego.NewClient(config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client.Challenge.Exclude([]challenge.Type{challenge.DNS01, challenge.HTTP01})
|
||||||
|
err = client.Challenge.SetTLSALPN01Address(":5001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
user.registration = reg
|
||||||
|
|
||||||
|
csrRaw, err := ioutil.ReadFile("./fixtures/csr.raw")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
csr, err := x509.ParseCertificateRequest(csrRaw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resource, err := client.Certificate.ObtainForCSR(*csr, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NotNil(t, resource)
|
||||||
|
assert.Equal(t, "acme.wtf", resource.Domain)
|
||||||
|
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL)
|
||||||
|
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL)
|
||||||
|
assert.NotEmpty(t, resource.Certificate)
|
||||||
|
assert.NotEmpty(t, resource.IssuerCertificate)
|
||||||
|
assert.NotEmpty(t, resource.CSR)
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeUser struct {
|
||||||
|
email string
|
||||||
|
privateKey crypto.PrivateKey
|
||||||
|
registration *registration.Resource
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUser) GetEmail() string { return f.email }
|
||||||
|
func (f *fakeUser) GetRegistration() *registration.Resource { return f.registration }
|
||||||
|
func (f *fakeUser) GetPrivateKey() crypto.PrivateKey { return f.privateKey }
|
137
e2e/dnschallenge/dns_challenges_test.go
Normal file
137
e2e/dnschallenge/dns_challenges_test.go
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
package dnschallenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/xenolf/lego/certificate"
|
||||||
|
"github.com/xenolf/lego/challenge"
|
||||||
|
"github.com/xenolf/lego/challenge/dns01"
|
||||||
|
"github.com/xenolf/lego/e2e/loader"
|
||||||
|
"github.com/xenolf/lego/lego"
|
||||||
|
"github.com/xenolf/lego/providers/dns"
|
||||||
|
"github.com/xenolf/lego/registration"
|
||||||
|
)
|
||||||
|
|
||||||
|
var load = loader.EnvLoader{
|
||||||
|
PebbleOptions: &loader.CmdOption{
|
||||||
|
HealthCheckURL: "https://localhost:15000/dir",
|
||||||
|
Args: []string{"-strict", "-config", "fixtures/pebble-config-dns.json", "-dnsserver", "localhost:8053"},
|
||||||
|
Env: []string{"PEBBLE_VA_NOSLEEP=1", "PEBBLE_WFE_NONCEREJECT=20"},
|
||||||
|
Dir: "../",
|
||||||
|
},
|
||||||
|
LegoOptions: []string{
|
||||||
|
"LEGO_CA_CERTIFICATES=../fixtures/certs/pebble.minica.pem",
|
||||||
|
"EXEC_PATH=../fixtures/update-dns.sh",
|
||||||
|
},
|
||||||
|
ChallSrv: &loader.CmdOption{
|
||||||
|
Args: []string{"-http01", ":5012", "-tlsalpn01", ":5011"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
os.Exit(load.MainTest(m))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDNSHelp(t *testing.T) {
|
||||||
|
output, err := load.RunLego("dnshelp")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", output)
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stdout, "%s\n", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChallengeDNS_Run(t *testing.T) {
|
||||||
|
loader.CleanLegoFiles()
|
||||||
|
|
||||||
|
output, err := load.RunLego(
|
||||||
|
"-m", "hubert@hubert.com",
|
||||||
|
"--accept-tos",
|
||||||
|
"-x", "http-01",
|
||||||
|
"-x", "tls-alpn-01",
|
||||||
|
"--dns-disable-cp",
|
||||||
|
"--dns-resolvers", ":8053",
|
||||||
|
"--dns", "exec",
|
||||||
|
"-s", "https://localhost:15000/dir",
|
||||||
|
"-d", "*.légo.acme",
|
||||||
|
"-d", "légo.acme",
|
||||||
|
"--http", ":5004",
|
||||||
|
"--tls", ":5003",
|
||||||
|
"run")
|
||||||
|
|
||||||
|
if len(output) > 0 {
|
||||||
|
fmt.Fprintf(os.Stdout, "%s\n", output)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChallengeDNS_Client_Obtain(t *testing.T) {
|
||||||
|
err := os.Setenv("LEGO_CA_CERTIFICATES", "../fixtures/certs/pebble.minica.pem")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
|
||||||
|
|
||||||
|
err = os.Setenv("EXEC_PATH", "../fixtures/update-dns.sh")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = os.Unsetenv("EXEC_PATH") }()
|
||||||
|
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err, "Could not generate test key")
|
||||||
|
|
||||||
|
user := &fakeUser{privateKey: privateKey}
|
||||||
|
config := lego.NewConfig(user)
|
||||||
|
config.CADirURL = "https://localhost:15000/dir"
|
||||||
|
|
||||||
|
client, err := lego.NewClient(config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
provider, err := dns.NewDNSChallengeProviderByName("exec")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = client.Challenge.SetDNS01Provider(provider,
|
||||||
|
dns01.AddRecursiveNameservers([]string{":8053"}),
|
||||||
|
dns01.DisableCompletePropagationRequirement())
|
||||||
|
client.Challenge.Exclude([]challenge.Type{challenge.HTTP01, challenge.TLSALPN01})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
user.registration = reg
|
||||||
|
|
||||||
|
domains := []string{"*.légo.acme", "légo.acme"}
|
||||||
|
|
||||||
|
request := certificate.ObtainRequest{
|
||||||
|
Domains: domains,
|
||||||
|
Bundle: true,
|
||||||
|
PrivateKey: privateKey,
|
||||||
|
}
|
||||||
|
resource, err := client.Certificate.Obtain(request)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NotNil(t, resource)
|
||||||
|
assert.Equal(t, "*.xn--lgo-bma.acme", resource.Domain)
|
||||||
|
assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertURL)
|
||||||
|
assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertStableURL)
|
||||||
|
assert.NotEmpty(t, resource.Certificate)
|
||||||
|
assert.NotEmpty(t, resource.IssuerCertificate)
|
||||||
|
assert.Empty(t, resource.CSR)
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeUser struct {
|
||||||
|
email string
|
||||||
|
privateKey crypto.PrivateKey
|
||||||
|
registration *registration.Resource
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUser) GetEmail() string { return f.email }
|
||||||
|
func (f *fakeUser) GetRegistration() *registration.Resource { return f.registration }
|
||||||
|
func (f *fakeUser) GetPrivateKey() crypto.PrivateKey { return f.privateKey }
|
25
e2e/fixtures/certs/README.md
Normal file
25
e2e/fixtures/certs/README.md
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
# certs/
|
||||||
|
|
||||||
|
This directory contains a CA certificate (`pebble.minica.pem`) and a private key
|
||||||
|
(`pebble.minica.key.pem`) that are used to issue a end-entity certificate (See
|
||||||
|
`certs/localhost`) for the Pebble HTTPS server.
|
||||||
|
|
||||||
|
To get your **testing code** to use Pebble without HTTPS errors you should
|
||||||
|
configure your ACME client to trust the `pebble.minica.pem` CA certificate. Your
|
||||||
|
ACME client should offer a runtime option to specify a list of root CAs that you
|
||||||
|
can configure to include the `pebble.minica.pem` file.
|
||||||
|
|
||||||
|
**Do not** add this CA certificate to the system trust store or in production
|
||||||
|
code!!! The CA's private key is **public** and anyone can use it to issue
|
||||||
|
certificates that will be trusted by a system with the Pebble CA in the trust
|
||||||
|
store.
|
||||||
|
|
||||||
|
To re-create all of the Pebble certificates run:
|
||||||
|
|
||||||
|
minica -ca-cert pebble.minica.pem \
|
||||||
|
-ca-key pebble.minica.key.pem \
|
||||||
|
-domains localhost,pebble \
|
||||||
|
-ip-addresses 127.0.0.1
|
||||||
|
|
||||||
|
From the `test/certs/` directory after [installing
|
||||||
|
MiniCA](https://github.com/jsha/minica#installation)
|
5
e2e/fixtures/certs/localhost/README.md
Normal file
5
e2e/fixtures/certs/localhost/README.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
# certs/localhost
|
||||||
|
|
||||||
|
This directory contains an end-entity (leaf) certificate (`cert.pem`) and
|
||||||
|
a private key (`key.pem`) for the Pebble HTTPS server. It includes `127.0.0.1`
|
||||||
|
as an IP address SAN, and `[localhost, pebble]` as DNS SANs.
|
19
e2e/fixtures/certs/localhost/cert.pem
Normal file
19
e2e/fixtures/certs/localhost/cert.pem
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDGzCCAgOgAwIBAgIIbEfayDFsBtwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
|
||||||
|
AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMDcx
|
||||||
|
MjA2MTk0MjEwWjAUMRIwEAYDVQQDEwlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
|
||||||
|
AQUAA4IBDwAwggEKAoIBAQCbFMW3DXXdErvQf2lCZ0qz0DGEWadDoF0O2neM5mVa
|
||||||
|
VQ7QGW0xc5Qwvn3Tl62C0JtwLpF0pG2BICIN+DHdVaIUwkf77iBS2doH1I3waE1I
|
||||||
|
8GkV9JrYmFY+j0dA1SwBmqUZNXhLNwZGq1a91nFSI59DZNy/JciqxoPX2K++ojU2
|
||||||
|
FPpuXe2t51NmXMsszpa+TDqF/IeskA9A/ws6UIh4Mzhghx7oay2/qqj2IIPjAmJj
|
||||||
|
i73kdUvtEry3wmlkBvtVH50+FscS9WmPC5h3lDTk5nbzSAXKuFusotuqy3XTgY5B
|
||||||
|
PiRAwkZbEY43JNfqenQPHo7mNTt29i+NVVrBsnAa5ovrAgMBAAGjYzBhMA4GA1Ud
|
||||||
|
DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0T
|
||||||
|
AQH/BAIwADAiBgNVHREEGzAZgglsb2NhbGhvc3SCBnBlYmJsZYcEfwAAATANBgkq
|
||||||
|
hkiG9w0BAQsFAAOCAQEAYIkXff8H28KS0KyLHtbbSOGU4sujHHVwiVXSATACsNAE
|
||||||
|
D0Qa8hdtTQ6AUqA6/n8/u1tk0O4rPE/cTpsM3IJFX9S3rZMRsguBP7BSr1Lq/XAB
|
||||||
|
7JP/CNHt+Z9aKCKcg11wIX9/B9F7pyKM3TdKgOpqXGV6TMuLjg5PlYWI/07lVGFW
|
||||||
|
/mSJDRs8bSCFmbRtEqc4lpwlrpz+kTTnX6G7JDLfLWYw/xXVqwFfdengcDTHCc8K
|
||||||
|
wtgGq/Gu6vcoBxIO3jaca+OIkMfxxXmGrcNdseuUCa3RMZ8Qy03DqGu6Y6XQyK4B
|
||||||
|
W8zIG6H9SVKkAznM2yfYhW8v2ktcaZ95/OBHY97ZIw==
|
||||||
|
-----END CERTIFICATE-----
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue