mirror of
				https://github.com/go-acme/lego.git
				synced 2025-10-31 08:27:38 +02:00 
			
		
		
		
	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:
		
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			
						parent
						
							4e842a5eb6
						
					
				
				
					commit
					42941ccea6
				
			
							
								
								
									
										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
		Reference in New Issue
	
	Block a user