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 | ||||
| .gitcookies | ||||
| .idea | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| [run] | ||||
|   deadline = "2m" | ||||
|   deadline = "5m" | ||||
|   skip-files = [] | ||||
|  | ||||
| [linters-settings] | ||||
|  | ||||
| @@ -7,13 +8,13 @@ | ||||
|     check-shadowing = true | ||||
|  | ||||
|   [linters-settings.gocyclo] | ||||
|     min-complexity = 16.0 | ||||
|     min-complexity = 12.0 | ||||
|  | ||||
|   [linters-settings.maligned] | ||||
|     suggest-new = true | ||||
|  | ||||
|   [linters-settings.goconst] | ||||
|     min-len = 2.0 | ||||
|     min-len = 3.0 | ||||
|     min-occurrences = 3.0 | ||||
|  | ||||
|   [linters-settings.misspell] | ||||
| @@ -27,15 +28,27 @@ | ||||
|     "gas", | ||||
|     "dupl", | ||||
|     "prealloc", | ||||
|     "scopelint", | ||||
|   ] | ||||
|  | ||||
| [issues] | ||||
|   exclude-use-default = false | ||||
|   max-per-linter = 0 | ||||
|   max-same = 0 | ||||
|   exclude = [ | ||||
|     "func (.+)disableAuthz(.) is unused", # acme/client.go#disableAuthz | ||||
|     "type (.+)deactivateAuthMessage(.) is unused", # acme/messages.go#deactivateAuthMessage | ||||
|     "(.)limitReader(.) - (.)numBytes(.) always receives (.)1048576(.)", # acme/crypto.go#limitReader | ||||
|     "cyclomatic complexity (\\d+) of func (.)NewDNSChallengeProviderByName(.) is high", # providers/dns/dns_providers.go#NewDNSChallengeProviderByName | ||||
|     "cyclomatic complexity (\\d+) of func (.)setup(.) is high", # cli_handler.go#setup | ||||
|     "Error return value of (.+) is not checked", | ||||
|     "exported (type|method|function) (.+) should have comment or be unexported", | ||||
|     "possible misuse of unsafe.Pointer", | ||||
|     "cyclomatic complexity (.+) of func `NewDNSChallengeProviderByName` is high (.+)", # providers/dns/dns_providers.go | ||||
|  | ||||
|     "`(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: | ||||
|   - binary: lego | ||||
|  | ||||
|     main: ./cmd/lego/main.go | ||||
|     ldflags: | ||||
|       - -s -w -X main.version={{.Version}} | ||||
|  | ||||
|     goos: | ||||
|       - windows | ||||
|       - darwin | ||||
|   | ||||
							
								
								
									
										19
									
								
								.travis.yml
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								.travis.yml
									
									
									
									
									
								
							| @@ -1,12 +1,21 @@ | ||||
| language: go | ||||
|  | ||||
| go: | ||||
|   - 1.9.x | ||||
|   - 1.10.x | ||||
|   - 1.x | ||||
|  | ||||
| services: | ||||
|   - memcached | ||||
|  | ||||
| addons: | ||||
|   hosts: | ||||
|   # for e2e tests | ||||
|   - acme.wtf | ||||
|   - lego.wtf | ||||
|   - acme.lego.wtf | ||||
|   - légô.wtf | ||||
|   - xn--lg-bja9b.wtf | ||||
|  | ||||
| env: | ||||
|   - 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 - | ||||
|   - 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 | ||||
|   - 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 | ||||
|  | ||||
| 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)                                          | -                                                                 | | ||||
| | Name.com                  | `namedotcom`   | [documentation](https://www.name.com/api-docs/DNS)                                                           | [Go client](https://github.com/namedotcom/go)                     | | ||||
| | manual                    | `manual`       | -                                                                                                            | -                                                                 | | ||||
| | MyDNS.jp                  | `mydnsjp`      | [documentation](https://www.mydns.jp/?MENU=030)                                                              | -                                                                 | | ||||
| | Netcup                    | `netcup`       | [documentation](https://www.netcup-wiki.de/wiki/DNS_API)                                                     | -                                                                 | | ||||
| | 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)                        | | ||||
| @@ -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)                                                         | -                                                                 | | ||||
| | 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)                | | ||||
| | 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)                                        | -                                                                 | | ||||
| | 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)            | | ||||
| | 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)                      | -                                                                 | | ||||
| @@ -10,5 +10,5 @@ RUN make build | ||||
|  | ||||
| FROM alpine:3.8 | ||||
| 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" ] | ||||
|   | ||||
							
								
								
									
										29
									
								
								Gopkg.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										29
									
								
								Gopkg.lock
									
									
									
										generated
									
									
									
								
							| @@ -176,20 +176,20 @@ | ||||
|   revision = "5448fe645cb1964ba70ac8f9f2ffe975e61a536c" | ||||
|  | ||||
| [[projects]] | ||||
|   branch = "master" | ||||
|   digest = "1:6b873be0e0ec65484ee086d02143f31332e363b968fdc6d6663160fa98fda505" | ||||
|   digest = "1:e856fc44ab196970612bdc8c15e65ccf92ed8d4ccb3a2e65b88dc240a2fe5d0b" | ||||
|   name = "github.com/dnsimple/dnsimple-go" | ||||
|   packages = ["dnsimple"] | ||||
|   pruneopts = "NUT" | ||||
|   revision = "35bcc6b47c20ec9bf3a53adcb7fa9665a75f0e7b" | ||||
|   revision = "f5ead9c20763fd925dea1362f2af5d671ed2a459" | ||||
|   version = "v0.21.0" | ||||
|  | ||||
| [[projects]] | ||||
|   digest = "1:e096f1857eedd49e2bd0885d05105d1d4af1bfcf8b1d07fa5710718e6641fd48" | ||||
|   digest = "1:e68d50b8dc605565eb62df1c2b2c67fa729e5b55aa1a6c81456eecbe0326ecdb" | ||||
|   name = "github.com/exoscale/egoscale" | ||||
|   packages = ["."] | ||||
|   pruneopts = "NUT" | ||||
|   revision = "0863d555d5198557e0bf2b61b6c59a873ab0173a" | ||||
|   version = "v0.11.1" | ||||
|   revision = "67368ae928a70cb5cb44ecf6f418ee33a1ade044" | ||||
|   version = "v0.11.6" | ||||
|  | ||||
| [[projects]] | ||||
|   digest = "1:aa3ed0a71c4e66e4ae6486bf97a3f4cab28edc78df2e50c5ad01dc7d91604b88" | ||||
| @@ -298,12 +298,12 @@ | ||||
|   version = "v0.5.1" | ||||
|  | ||||
| [[projects]] | ||||
|   digest = "1:24b5f8d41224b90e3f4d22768926ed782a8ca481d945c0e064c8f165bf768280" | ||||
|   digest = "1:6676c63cef61a47c84eae578bcd8fe8352908ccfe3ea663c16797617a29e3c44" | ||||
|   name = "github.com/miekg/dns" | ||||
|   packages = ["."] | ||||
|   pruneopts = "NUT" | ||||
|   revision = "5a2b9fab83ff0f8bfc99684bd5f43a37abe560f1" | ||||
|   version = "v1.0.8" | ||||
|   revision = "a220737569d8137d4c610f80bd33f1dc762522e5" | ||||
|   version = "v1.1.0" | ||||
|  | ||||
| [[projects]] | ||||
|   digest = "1:a4df73029d2c42fabcb6b41e327d2f87e685284ec03edf76921c267d9cfc9c23" | ||||
| @@ -379,7 +379,7 @@ | ||||
|  | ||||
| [[projects]] | ||||
|   branch = "master" | ||||
|   digest = "1:0f9362b2768972675cf28574249bfb5dd65556aac6ad1c36830b4bc8c2134926" | ||||
|   digest = "1:180e8ec2d3734b269a8a30b51dbca47fede2ce274fa76da2f00e664481cfb39e" | ||||
|   name = "github.com/sacloud/libsacloud" | ||||
|   packages = [ | ||||
|     ".", | ||||
| @@ -388,7 +388,7 @@ | ||||
|     "sacloud/ostype", | ||||
|   ] | ||||
|   pruneopts = "NUT" | ||||
|   revision = "7afff3fbc0a3bdff2e008fe2c429d44d9f66f209" | ||||
|   revision = "108b1efe4b4d106fee6760bdf1847c4f92e1a92e" | ||||
|  | ||||
| [[projects]] | ||||
|   digest = "1:6bc0652ea6e39e22ccd522458b8bdd8665bf23bdc5a20eec90056e4dc7e273ca" | ||||
| @@ -613,7 +613,7 @@ | ||||
|   revision = "028658c6d9be774b6d103a923d8c4b2715135c3f" | ||||
|  | ||||
| [[projects]] | ||||
|   digest = "1:3b7124c543146736e07107be13ea6288923c4a743e07c7a31d6b7209a00a9dab" | ||||
|   digest = "1:a50fabe7a46692dc7c656310add3d517abe7914df02afd151ef84da884605dc8" | ||||
|   name = "gopkg.in/square/go-jose.v2" | ||||
|   packages = [ | ||||
|     ".", | ||||
| @@ -621,8 +621,8 @@ | ||||
|     "json", | ||||
|   ] | ||||
|   pruneopts = "NUT" | ||||
|   revision = "8254d6c783765f38c8675fae4427a1fe73fbd09d" | ||||
|   version = "v2.1.8" | ||||
|   revision = "ef984e69dd356202fd4e4910d4d9c24468bdf0b8" | ||||
|   version = "v2.1.9" | ||||
|  | ||||
| [solve-meta] | ||||
|   analyzer-name = "dep" | ||||
| @@ -676,6 +676,7 @@ | ||||
|     "github.com/urfave/cli", | ||||
|     "golang.org/x/crypto/ocsp", | ||||
|     "golang.org/x/net/context", | ||||
|     "golang.org/x/net/idna", | ||||
|     "golang.org/x/net/publicsuffix", | ||||
|     "golang.org/x/oauth2", | ||||
|     "golang.org/x/oauth2/clientcredentials", | ||||
|   | ||||
| @@ -34,7 +34,7 @@ | ||||
|   name = "github.com/decker502/dnspod-go" | ||||
|  | ||||
| [[constraint]] | ||||
|   branch = "master" | ||||
|   version = "0.21.0" | ||||
|   name = "github.com/dnsimple/dnsimple-go" | ||||
|  | ||||
| [[constraint]] | ||||
| @@ -92,3 +92,7 @@ | ||||
| [[constraint]] | ||||
|   version = "0.11.1" | ||||
|   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 | ||||
|  | ||||
| SRCS = $(shell git ls-files '*.go' | grep -v '^vendor/') | ||||
|  | ||||
| LEGO_IMAGE := xenolf/lego | ||||
| MAIN_DIRECTORY := ./cmd/lego/ | ||||
| BIN_OUTPUT := dist/lego | ||||
|  | ||||
| TAG_NAME := $(shell git tag -l --contains HEAD) | ||||
| SHA := $(shell git rev-parse HEAD) | ||||
| @@ -13,7 +17,11 @@ clean: | ||||
|  | ||||
| build: clean | ||||
| 	@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: | ||||
| 	dep ensure -v | ||||
| @@ -21,9 +29,11 @@ dependencies: | ||||
| test: clean | ||||
| 	go test -v -cover ./... | ||||
|  | ||||
| e2e: clean | ||||
| 	LEGO_E2E_TESTS=local go test -count=1 -v ./e2e/... | ||||
|  | ||||
| checks: | ||||
| 	golangci-lint run | ||||
|  | ||||
| image: | ||||
| 	@echo Version: $(VERSION) | ||||
| 	docker build -t $(LEGO_IMAGE) . | ||||
| fmt: | ||||
| 	gofmt -s -l -w $(SRCS) | ||||
|   | ||||
							
								
								
									
										233
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										233
									
								
								README.md
									
									
									
									
									
								
							| @@ -34,7 +34,7 @@ yaourt -S lego-git | ||||
| To install from source, just run: | ||||
|  | ||||
| ```bash | ||||
| go get -u github.com/xenolf/lego | ||||
| go get -u github.com/xenolf/lego/cmd/lego | ||||
| ``` | ||||
|  | ||||
| ## Features | ||||
| @@ -71,29 +71,31 @@ COMMANDS: | ||||
|      revoke   Revoke a certificate | ||||
|      renew    Renew a certificate | ||||
|      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 | ||||
|  | ||||
| GLOBAL OPTIONS: | ||||
|    --domains value, -d value   Add a domain to the process. Can be specified multiple times. | ||||
|    --csr value, -c value       Certificate signing request filename, if an external CSR is to be used | ||||
|    --server value, -s value    CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. (default: "https://acme-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. | ||||
|    --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. | ||||
|    --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. | ||||
|    --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") | ||||
|    --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 | ||||
|    --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 | ||||
|    --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. | ||||
|    --http-timeout value        Set the HTTP timeout value to a specific value in seconds. The default is 10 seconds. (default: 0) | ||||
|    --dns-timeout value         Set the DNS timeout value to a specific value in seconds. The default is 10 seconds. (default: 0) | ||||
|    --dns-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-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. | ||||
|    --help, -h                  show help | ||||
|    --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 … | ||||
| ``` | ||||
|  | ||||
| ## 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 | ||||
|  | ||||
| ### AWS Route 53 | ||||
| @@ -183,108 +283,31 @@ Replace `<INSERT_YOUR_HOSTED_ZONE_ID_HERE>` with the Route 53 zone ID of the dom | ||||
|  | ||||
| ```json | ||||
| { | ||||
|     "Version": "2012-10-17", | ||||
|     "Statement": [ | ||||
|         { | ||||
|             "Effect": "Allow", | ||||
|             "Action": [ | ||||
|                 "route53:GetChange", | ||||
|                 "route53:ListHostedZonesByName" | ||||
|             ], | ||||
|             "Resource": [ | ||||
|                 "*" | ||||
|             ] | ||||
|         }, | ||||
|         { | ||||
|             "Effect": "Allow", | ||||
|             "Action": [ | ||||
|                 "route53:ChangeResourceRecordSets" | ||||
|             ], | ||||
|             "Resource": [ | ||||
|                 "arn:aws:route53:::hostedzone/<INSERT_YOUR_HOSTED_ZONE_ID_HERE>" | ||||
|             ] | ||||
|         } | ||||
|     ] | ||||
|    "Version": "2012-10-17", | ||||
|    "Statement": [ | ||||
|        { | ||||
|            "Sid": "", | ||||
|            "Effect": "Allow", | ||||
|            "Action": [ | ||||
|                "route53:GetChange", | ||||
|                "route53:ChangeResourceRecordSets", | ||||
|                "route53:ListResourceRecordSets" | ||||
|            ], | ||||
|            "Resource": [ | ||||
|                "arn:aws:route53:::hostedzone/*", | ||||
|                "arn:aws:route53:::change/*" | ||||
|            ] | ||||
|        }, | ||||
|        { | ||||
|            "Sid": "", | ||||
|            "Effect": "Allow", | ||||
|            "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 | ||||
|  | ||||
| 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 ( | ||||
| 	"fmt" | ||||
| @@ -9,31 +9,31 @@ import ( | ||||
| 	"github.com/xenolf/lego/log" | ||||
| ) | ||||
| 
 | ||||
| // HTTPProviderServer implements ChallengeProvider for `http-01` challenge | ||||
| // It may be instantiated without using the NewHTTPProviderServer function if | ||||
| // ProviderServer implements ChallengeProvider for `http-01` challenge | ||||
| // It may be instantiated without using the NewProviderServer function if | ||||
| // you want only to use the default values. | ||||
| type HTTPProviderServer struct { | ||||
| type ProviderServer struct { | ||||
| 	iface    string | ||||
| 	port     string | ||||
| 	done     chan bool | ||||
| 	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 | ||||
| // the "any" interface and port 80 respectively. | ||||
| func NewHTTPProviderServer(iface, port string) *HTTPProviderServer { | ||||
| 	return &HTTPProviderServer{iface: iface, port: port} | ||||
| func NewProviderServer(iface, port string) *ProviderServer { | ||||
| 	return &ProviderServer{iface: iface, port: port} | ||||
| } | ||||
| 
 | ||||
| // Present starts a web server and makes the token available at `HTTP01ChallengePath(token)` for web requests. | ||||
| func (s *HTTPProviderServer) Present(domain, token, keyAuth string) error { | ||||
| // Present starts a web server and makes the token available at `ChallengePath(token)` for web requests. | ||||
| func (s *ProviderServer) Present(domain, token, keyAuth string) error { | ||||
| 	if s.port == "" { | ||||
| 		s.port = "80" | ||||
| 	} | ||||
| 
 | ||||
| 	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 { | ||||
| 		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 | ||||
| } | ||||
| 
 | ||||
| // CleanUp closes the HTTP server and removes the token from `HTTP01ChallengePath(token)` | ||||
| func (s *HTTPProviderServer) CleanUp(domain, token, keyAuth string) error { | ||||
| func (s *ProviderServer) GetAddress() string { | ||||
| 	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 { | ||||
| 		return nil | ||||
| 	} | ||||
| @@ -53,8 +57,8 @@ func (s *HTTPProviderServer) CleanUp(domain, token, keyAuth string) error { | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *HTTPProviderServer) serve(domain, token, keyAuth string) { | ||||
| 	path := HTTP01ChallengePath(token) | ||||
| func (s *ProviderServer) serve(domain, token, keyAuth string) { | ||||
| 	path := ChallengePath(token) | ||||
| 
 | ||||
| 	// The handler validates the HOST header and request type. | ||||
| 	// 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} | ||||
| 
 | ||||
| 	// Once httpServer is shut down we don't want any lingering | ||||
| 	// connections, so disable KeepAlives. | ||||
| 	// Once httpServer is shut down | ||||
| 	// we don't want any lingering connections, so disable KeepAlives. | ||||
| 	httpServer.SetKeepAlivesEnabled(false) | ||||
| 
 | ||||
| 	err := httpServer.Serve(s.listener) | ||||
| 	if err != nil { | ||||
| 	if err != nil && !strings.Contains(err.Error(), "use of closed network connection") { | ||||
| 		log.Println(err) | ||||
| 	} | ||||
| 	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" | ||||
| 
 | ||||
| // ChallengeProvider enables implementing a custom challenge | ||||
| // Provider enables implementing a custom challenge | ||||
| // provider. Present presents the solution to a challenge available to | ||||
| // be solved. CleanUp will be called by the challenge if Present ends | ||||
| // in a non-error state. | ||||
| type ChallengeProvider interface { | ||||
| type Provider interface { | ||||
| 	Present(domain, token, keyAuth string) error | ||||
| 	CleanUp(domain, token, keyAuth string) error | ||||
| } | ||||
| 
 | ||||
| // ChallengeProviderTimeout allows for implementing a | ||||
| // ChallengeProvider where an unusually long timeout is required when | ||||
| // ProviderTimeout allows for implementing a | ||||
| // Provider where an unusually long timeout is required when | ||||
| // waiting for an ACME challenge to be satisfied, such as when | ||||
| // checking for DNS record progagation. If an implementor of a | ||||
| // ChallengeProvider provides a Timeout method, then the return values | ||||
| // checking for DNS record propagation. If an implementor of a | ||||
| // Provider provides a Timeout method, then the return values | ||||
| // of the Timeout method will be used when appropriate by the acme | ||||
| // package. The interval value is the time between checks. | ||||
| // | ||||
| // The default values used for timeout and interval are 60 seconds and | ||||
| // 2 seconds respectively. These are used when no Timeout method is | ||||
| // defined for the ChallengeProvider. | ||||
| type ChallengeProviderTimeout interface { | ||||
| 	ChallengeProvider | ||||
| // defined for the Provider. | ||||
| type ProviderTimeout interface { | ||||
| 	Provider | ||||
| 	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 ( | ||||
| 	"crypto/tls" | ||||
| 	"fmt" | ||||
| 	"log" | ||||
| 	"net" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"github.com/xenolf/lego/log" | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	// ACMETLS1Protocol is the ALPN Protocol ID for the ACME-TLS/1 Protocol. | ||||
| 	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. | ||||
| 	defaultTLSPort = "443" | ||||
| ) | ||||
| 
 | ||||
| // TLSALPNProviderServer implements ChallengeProvider for `TLS-ALPN-01` | ||||
| // challenge. It may be instantiated without using the NewTLSALPNProviderServer | ||||
| // ProviderServer implements ChallengeProvider for `TLS-ALPN-01` challenge. | ||||
| // It may be instantiated without using the NewProviderServer | ||||
| // if you want only to use the default values. | ||||
| type TLSALPNProviderServer struct { | ||||
| type ProviderServer struct { | ||||
| 	iface    string | ||||
| 	port     string | ||||
| 	listener net.Listener | ||||
| } | ||||
| 
 | ||||
| // NewTLSALPNProviderServer creates a new TLSALPNProviderServer on the selected | ||||
| // interface and port. Setting iface and / or port to an empty string will make | ||||
| // the server fall back to the "any" interface and port 443 respectively. | ||||
| func NewTLSALPNProviderServer(iface, port string) *TLSALPNProviderServer { | ||||
| 	return &TLSALPNProviderServer{iface: iface, port: 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 | ||||
| // the "any" interface and port 443 respectively. | ||||
| func NewProviderServer(iface, port string) *ProviderServer { | ||||
| 	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 | ||||
| // as the acmeValidation-v1 extension value to conform to the ACME-TLS-ALPN | ||||
| // spec. | ||||
| func (t *TLSALPNProviderServer) Present(domain, token, keyAuth string) error { | ||||
| 	if t.port == "" { | ||||
| // as the acmeValidation-v1 extension value to conform to the ACME-TLS-ALPN spec. | ||||
| func (s *ProviderServer) Present(domain, token, keyAuth string) error { | ||||
| 	if s.port == "" { | ||||
| 		// 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. | ||||
| 	cert, err := TLSALPNChallengeCert(domain, keyAuth) | ||||
| 	cert, err := ChallengeCert(domain, keyAuth) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| @@ -59,15 +64,15 @@ func (t *TLSALPNProviderServer) Present(domain, token, keyAuth string) error { | ||||
| 	tlsConf.NextProtos = []string{ACMETLS1Protocol} | ||||
| 
 | ||||
| 	// 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 { | ||||
| 		return fmt.Errorf("could not start HTTPS server for challenge -> %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Shut the server down when we're finished. | ||||
| 	go func() { | ||||
| 		err := http.Serve(t.listener, nil) | ||||
| 		if err != nil { | ||||
| 		err := http.Serve(s.listener, nil) | ||||
| 		if err != nil && !strings.Contains(err.Error(), "use of closed network connection") { | ||||
| 			log.Println(err) | ||||
| 		} | ||||
| 	}() | ||||
| @@ -76,13 +81,13 @@ func (t *TLSALPNProviderServer) Present(domain, token, keyAuth string) error { | ||||
| } | ||||
| 
 | ||||
| // CleanUp closes the HTTPS server. | ||||
| func (t *TLSALPNProviderServer) CleanUp(domain, token, keyAuth string) error { | ||||
| 	if t.listener == nil { | ||||
| func (s *ProviderServer) CleanUp(domain, token, keyAuth string) error { | ||||
| 	if s.listener == nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	// 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 | ||||
| 	} | ||||
| 
 | ||||
| @@ -1,4 +1,4 @@ | ||||
| package acme | ||||
| package tlsalpn01 | ||||
| 
 | ||||
| import ( | ||||
| 	"crypto/rand" | ||||
| @@ -7,16 +7,24 @@ import ( | ||||
| 	"crypto/subtle" | ||||
| 	"crypto/tls" | ||||
| 	"encoding/asn1" | ||||
| 	"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 TestTLSALPNChallenge(t *testing.T) { | ||||
| func TestChallenge(t *testing.T) { | ||||
| 	_, apiURL, tearDown := tester.SetupFakeAPI() | ||||
| 	defer tearDown() | ||||
| 
 | ||||
| 	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{ | ||||
| 			InsecureSkipVerify: true, | ||||
| 		}) | ||||
| @@ -48,41 +56,64 @@ func TestTLSALPNChallenge(t *testing.T) { | ||||
| 		value, err := asn1.Marshal(zBytes[:sha256.Size]) | ||||
| 		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) | ||||
| 		} | ||||
| 
 | ||||
| 		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") | ||||
| 
 | ||||
| 	solver := &tlsALPNChallenge{ | ||||
| 		jws:      &jws{privKey: privKey}, | ||||
| 		validate: mockValidate, | ||||
| 		provider: &TLSALPNProviderServer{port: "23457"}, | ||||
| 	core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey) | ||||
| 	require.NoError(t, err) | ||||
| 
 | ||||
| 	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(clientChallenge, domain) | ||||
| 	err = solver.Solve(authz) | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
| 
 | ||||
| func TestTLSALPNChallengeInvalidPort(t *testing.T) { | ||||
| 	privKey, err := rsa.GenerateKey(rand.Reader, 128) | ||||
| 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") | ||||
| 
 | ||||
| 	solver := &tlsALPNChallenge{ | ||||
| 		jws:      &jws{privKey: privKey}, | ||||
| 		validate: stubValidate, | ||||
| 		provider: &TLSALPNProviderServer{port: "123456"}, | ||||
| 	core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey) | ||||
| 	require.NoError(t, err) | ||||
| 
 | ||||
| 	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(clientChallenge, "localhost:123456") | ||||
| 	err = solver.Solve(authz) | ||||
| 	require.Error(t, err) | ||||
| 	assert.Contains(t, err.Error(), "invalid port") | ||||
| 	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! | ||||
| // CLI application for generating Let's Encrypt certificates using the ACME package. | ||||
| package main | ||||
| package cmd | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"text/tabwriter" | ||||
| 
 | ||||
| 	"github.com/urfave/cli" | ||||
| 	"github.com/xenolf/lego/acme" | ||||
| 	"github.com/xenolf/lego/log" | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	version = "dev" | ||||
| ) | ||||
| 
 | ||||
| func main() { | ||||
| 	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) | ||||
| func createDNSHelp() cli.Command { | ||||
| 	return cli.Command{ | ||||
| 		Name:   "dnshelp", | ||||
| 		Usage:  "Shows additional help for the --dns global option", | ||||
| 		Action: dnsHelp, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| @@ -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, "\tcloudflare:\tCLOUDFLARE_EMAIL, CLOUDFLARE_API_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, "\tdnsimple:\tDNSIMPLE_EMAIL, DNSIMPLE_OAUTH_TOKEN") | ||||
| 	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, "\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, "\tstackpath:\tSTACKPATH_CLIENT_ID, STACKPATH_CLIENT_SECRET, STACKPATH_STACK_ID") | ||||
| 	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, "\tvultr:\tVULTR_API_KEY") | ||||
| 	fmt.Fprintln(w, "\tvscale:\tVSCALE_API_TOKEN") | ||||
| 	fmt.Fprintln(w, "\tvultr:\tVULTR_API_KEY") | ||||
| 	fmt.Fprintln(w) | ||||
| 	fmt.Fprintln(w, "Additional configuration environment variables:") | ||||
| 	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, "\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, "\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, "\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, "\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, "\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, "\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, "\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, "\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, "\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, "\thttpreq:\t,HTTPREQ_POLLING_INTERVAL, HTTPREQ_PROPAGATION_TIMEOUT, HTTPREQ_HTTP_TIMEOUT") | ||||
| 	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, "\tlinode:\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, "\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") | ||||
| @@ -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, "\troute53:\tAWS_POLLING_INTERVAL, AWS_PROPAGATION_TIMEOUT, AWS_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, "\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, "\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, "\tvultr:\tVULTR_POLLING_INTERVAL, VULTR_PROPAGATION_TIMEOUT, VULTR_TTL, VULTR_HTTP_TIMEOUT") | ||||
| 
 | ||||
| 	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