diff --git a/CHANGELOG.md b/CHANGELOG.md index 66bd1d83..af526da3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,29 @@ ## [Unreleased] +### Added: +- CLI: The `--exclude` or `-x` switch. To exclude a challenge from being solved. +- CLI: The `--http` switch. To set the listen address and port of HTTP based challenges. Supports `host:port` and `:port` for any interface. +- CLI: The `--tls` switch. To set the listen address and port of TLS based challenges. Supports `host:port` and `:port` for any interface. +- CLI: The `--reuse-key` switch for the `renew` operation. This lets you reuse an existing private key for renewals. +- lib: ExcludeChallenges function. Pass an array of challenge identifiers to exclude them from solving. +- lib: SetHTTPAddress function. Pass a port to set the listen port for HTTP based challenges. +- lib: SetTLSAddress function. Pass a port to set the listen port of TLS based challenges. +- lib: acme.UserAgent variable. Use this to customize the user agent on all requests sent by lego. + +### Changed: +- lib: NewClient does no longer accept the optPort parameter +- lib: ObtainCertificate now returns a SAN certificate if you pass more then one domain. +- lib: GetOCSPForCert now returns the parsed OCSP response instead of just the status. +- lib: ObtainCertificate has a new parameter `privKey crypto.PrivateKey` which lets you reuse an existing private key for new certificates. +- lib: RenewCertificate now expects the PrivateKey property of the CertificateResource to be set only if you want to reuse the key. + +### Removed: +- CLI: The `--port` switch was removed. +- lib: RenewCertificate does no longer offer to also revoke your old certificate. + ### Fixed: -- CLI: Fix logic using the --days parameter +- CLI: Fix logic using the `--days` parameter for renew ## [0.1.1] - 2015-12-18 diff --git a/README.md b/README.md index 36d9ee3d..a654c0d2 100644 --- a/README.md +++ b/README.md @@ -38,22 +38,26 @@ Current features: Please keep in mind that CLI switches and APIs are still subject to change. -When using the standard --path option, all certificates and account configurations are saved to a folder *.lego* in the current working directory. +When using the standard `--path` option, all certificates and account configurations are saved to a folder *.lego* in the current working directory. #### Sudo The CLI does not require root permissions but needs to bind to port 80 and 443 for certain challenges. To run the CLI without sudo, you have two options: - Use setcap 'cap_net_bind_service=+ep' /path/to/program -- Pass the `--port` option and specify a custom port to bind to. In this case you have to forward port 443 to this custom port. +- Pass the `--http` or/and the `--tls` option and specify a custom port to bind to. In this case you have to forward port 80/443 to these custom ports (see [Port Usage](#port-usage)). #### Port Usage By default lego assumes it is able to bind to ports 80 and 443 to solve challenges. -If this is not possible in your environment, you can use the `--port` option to instruct -lego to listen on that port for any incoming challenges. +If this is not possible in your environment, you can use the `--http` and `--tls` options to instruct +lego to listen on that interface:port for any incoming challenges. -If you are using this option, make sure you proxy all of the following traffic to that port: +If you are using this option, make sure you proxy all of the following traffic to these ports. + +HTTP Port: - All plaintext HTTP requests to port 80 which begin with a request path of `/.well-known/acme-challenge/` for the HTTP-01 challenge. + +TLS Port: - All TLS handshakes on port 443 for TLS-SNI-01. This traffic redirection is only needed as long as lego solves challenges. As soon as you have received your certificates you can deactivate the forwarding. @@ -68,7 +72,7 @@ USAGE: ./lego [global options] command [command options] [arguments...] VERSION: - 0.1.0 + 0.2.0 COMMANDS: run Register an account, then create and install a certificate @@ -81,8 +85,10 @@ GLOBAL OPTIONS: --server, -s "https://acme-v01.api.letsencrypt.org/directory" CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. --email, -m Email used for registration and recovery contact. --rsa-key-size, -B "2048" Size of the RSA key. - --path "${CWD}" Directory to use for storing the data - --port Challenges will use this port to listen on. Please make sure to forward port 443 to this port on your machine. Otherwise use setcap on the binary + --path "${CWD}/.lego" Directory to use for storing the data + --exclude, -x [--exclude option --exclude option] Explicitly disallow solvers by name from being used. Solvers: "http-01", "tls-sni-01". + --http Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port. + --tls Set the port and interface to use for TLS based challenges to listen on. Supported: interface:port or :port. --help, -h show help --version, -v print the version @@ -141,14 +147,18 @@ myUser := MyUser{ // 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. -// We specify an optPort of 5001 because we aren't running as root and can't -// bind a listener to port 80 or 443 (used later when we attempt to pass challenges). -// Keep in mind that we still need to proxy challenge traffic to port 5001. -client, err := acme.NewClient("http://192.168.99.100:4000", &myUser, rsaKeySize, "5001") +client, err := acme.NewClient("http://192.168.99.100:4000", &myUser, rsaKeySize) 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; be sure to save it reg, err := client.Register() if err != nil { @@ -166,7 +176,7 @@ if err != nil { // The acme library takes care of completing the challenges to obtain the certificate(s). // Of course, the hostnames must resolve to this machine or it will fail. bundle := false -certificates, err := client.ObtainCertificates([]string{"mydomain.com"}, bundle) +certificates, err := client.ObtainCertificate([]string{"mydomain.com"}, bundle, nil) if err != nil { log.Fatal(err) } diff --git a/acme/client.go b/acme/client.go index 90bcfe57..64b55b9f 100644 --- a/acme/client.go +++ b/acme/client.go @@ -1,6 +1,7 @@ package acme import ( + "crypto" "crypto/rsa" "crypto/x509" "encoding/base64" @@ -9,15 +10,17 @@ import ( "fmt" "io/ioutil" "log" - "net/http" + "net" "regexp" "strconv" "strings" "time" ) -// Logger is an optional custom logger. -var Logger *log.Logger +var ( + // Logger is an optional custom logger. + Logger *log.Logger +) // logf writes a log entry. It uses Logger if not // nil, otherwise it uses the default log.Logger. @@ -42,6 +45,8 @@ type solver interface { Solve(challenge challenge, domain string) error } +type validateFunc func(j *jws, domain, uri string, chlng challenge) error + // Client is the user-friendy way to ACME type Client struct { directory directory @@ -52,13 +57,10 @@ type Client struct { solvers map[string]solver } -// NewClient creates a new ACME client on behalf of user. The client will depend on +// NewClient creates a new ACME client on behalf of the user. The client will depend on // the ACME directory located at caDirURL for the rest of its actions. It will -// generate private keys for certificates of size keyBits. And, if the challenge -// type requires it, the client will open a port at optPort to solve the challenge. -// If optPort is blank, the port required by the spec will be used, but you must -// forward the required port to optPort for the challenge to succeed. -func NewClient(caDirURL string, user User, keyBits int, optPort string) (*Client, error) { +// generate private keys for certificates of size keyBits. +func NewClient(caDirURL string, user User, keyBits int) (*Client, error) { privKey := user.GetPrivateKey() if privKey == nil { return nil, errors.New("private key was nil") @@ -68,16 +70,9 @@ func NewClient(caDirURL string, user User, keyBits int, optPort string) (*Client return nil, fmt.Errorf("invalid private key: %v", err) } - dirResp, err := http.Get(caDirURL) - if err != nil { - return nil, fmt.Errorf("get directory at '%s': %v", caDirURL, err) - } - defer dirResp.Body.Close() - var dir directory - err = json.NewDecoder(dirResp.Body).Decode(&dir) - if err != nil { - return nil, fmt.Errorf("decode directory: %v", err) + if _, err := getJSON(caDirURL, &dir); err != nil { + return nil, fmt.Errorf("get directory at '%s': %v", caDirURL, err) } if dir.NewRegURL == "" { @@ -99,12 +94,56 @@ func NewClient(caDirURL string, user User, keyBits int, optPort string) (*Client // Add all available solvers with the right index as per ACME // spec to this map. Otherwise they won`t be found. solvers := make(map[string]solver) - solvers["http-01"] = &httpChallenge{jws: jws, optPort: optPort} - solvers["tls-sni-01"] = &tlsSNIChallenge{jws: jws, optPort: optPort} + solvers["http-01"] = &httpChallenge{jws: jws, validate: validate} + solvers["tls-sni-01"] = &tlsSNIChallenge{jws: jws, validate: validate} return &Client{directory: dir, user: user, jws: jws, keyBits: keyBits, solvers: solvers}, nil } +// SetHTTPAddress specifies a custom interface:port to be used for HTTP based challenges. +// If this option is not used, the default port 80 and all interfaces will be used. +// To only specify a port and no interface use the ":port" notation. +func (c *Client) SetHTTPAddress(iface string) error { + host, port, err := net.SplitHostPort(iface) + if err != nil { + return err + } + + if chlng, ok := c.solvers["http-01"]; ok { + chlng.(*httpChallenge).iface = host + chlng.(*httpChallenge).port = port + } + + return nil +} + +// SetTLSAddress specifies a custom interface:port to be used for TLS based challenges. +// If this option is not used, the default port 443 and all interfaces will be used. +// To only specify a port and no interface use the ":port" notation. +func (c *Client) SetTLSAddress(iface string) error { + host, port, err := net.SplitHostPort(iface) + if err != nil { + return err + } + + if chlng, ok := c.solvers["tls-sni-01"]; ok { + chlng.(*tlsSNIChallenge).iface = host + chlng.(*tlsSNIChallenge).port = port + } + + return nil +} + +// ExcludeChallenges explicitly removes challenges from the pool for solving. +func (c *Client) ExcludeChallenges(challenges []string) { + // Loop through all challenges and delete the requested one if found. + for _, challenge := range challenges { + if _, ok := c.solvers[challenge]; ok { + delete(c.solvers, challenge) + } + } +} + // Register the current account to the ACME server. func (c *Client) Register() (*RegistrationResource, error) { if c == nil || c.user == nil { @@ -121,32 +160,16 @@ func (c *Client) Register() (*RegistrationResource, error) { regMsg.Contact = []string{} } - jsonBytes, err := json.Marshal(regMsg) - if err != nil { - return nil, err - } - - resp, err := c.jws.post(c.directory.NewRegURL, jsonBytes) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode >= http.StatusBadRequest { - return nil, handleHTTPError(resp) - } - var serverReg Registration - decoder := json.NewDecoder(resp.Body) - err = decoder.Decode(&serverReg) + hdr, err := postJSON(c.jws, c.directory.NewRegURL, regMsg, &serverReg) if err != nil { return nil, err } reg := &RegistrationResource{Body: serverReg} - links := parseLinks(resp.Header["Link"]) - reg.URI = resp.Header.Get("Location") + links := parseLinks(hdr["Link"]) + reg.URI = hdr.Get("Location") if links["terms-of-service"] != "" { reg.TosURL = links["terms-of-service"] } @@ -165,76 +188,20 @@ func (c *Client) Register() (*RegistrationResource, error) { func (c *Client) AgreeToTOS() error { c.user.GetRegistration().Body.Agreement = c.user.GetRegistration().TosURL c.user.GetRegistration().Body.Resource = "reg" - jsonBytes, err := json.Marshal(&c.user.GetRegistration().Body) - if err != nil { - return err - } - - resp, err := c.jws.post(c.user.GetRegistration().URI, jsonBytes) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusAccepted { - return handleHTTPError(resp) - } - - return nil + _, err := postJSON(c.jws, c.user.GetRegistration().URI, c.user.GetRegistration().Body, nil) + return err } -// ObtainCertificates tries to obtain certificates from the CA server -// using the challenges it has configured. The returned certificates are -// PEM encoded byte slices. -// If bundle is true, the []byte contains both the issuer certificate and -// your issued certificate as a bundle. -func (c *Client) ObtainCertificates(domains []string, bundle bool) ([]CertificateResource, map[string]error) { - if bundle { - logf("[INFO][%s] acme: Obtaining bundled certificates", strings.Join(domains, ", ")) - } else { - logf("[INFO][%s] acme: Obtaining certificates", strings.Join(domains, ", ")) - } - - challenges, failures := c.getChallenges(domains) - if len(challenges) == 0 { - return nil, failures - } - - err := c.solveChallenges(challenges) - for k, v := range err { - failures[k] = v - } - - if len(failures) == len(domains) { - return nil, failures - } - - // remove failed challenges from slice - var succeededChallenges []authorizationResource - for _, chln := range challenges { - if failures[chln.Domain] == nil { - succeededChallenges = append(succeededChallenges, chln) - } - } - - logf("[INFO][%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", ")) - - certs, err := c.requestCertificates(succeededChallenges, bundle) - for k, v := range err { - failures[k] = v - } - - return certs, failures -} - -// ObtainSANCertificate tries to obtain a single certificate using all domains passed into it. +// 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. +// 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) ObtainSANCertificate(domains []string, bundle bool) (CertificateResource, map[string]error) { +func (c *Client) ObtainCertificate(domains []string, bundle bool, privKey crypto.PrivateKey) (CertificateResource, map[string]error) { if bundle { logf("[INFO][%s] acme: Obtaining bundled SAN certificate", strings.Join(domains, ", ")) } else { @@ -255,7 +222,7 @@ func (c *Client) ObtainSANCertificate(domains []string, bundle bool) (Certificat logf("[INFO][%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", ")) - cert, err := c.requestCertificate(challenges, bundle) + cert, err := c.requestCertificate(challenges, bundle, privKey) if err != nil { for _, chln := range challenges { failures[chln.Domain] = err @@ -279,22 +246,8 @@ func (c *Client) RevokeCertificate(certificate []byte) error { encodedCert := base64.URLEncoding.EncodeToString(x509Cert.Raw) - jsonBytes, err := json.Marshal(revokeCertMessage{Resource: "revoke-cert", Certificate: encodedCert}) - if err != nil { - return err - } - - resp, err := c.jws.post(c.directory.RevokeCertURL, jsonBytes) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return handleHTTPError(resp) - } - - return nil + _, err = postJSON(c.jws, c.directory.RevokeCertURL, revokeCertMessage{Resource: "revoke-cert", Certificate: encodedCert}, nil) + return err } // RenewCertificate takes a CertificateResource and tries to renew the certificate. @@ -304,7 +257,8 @@ func (c *Client) RevokeCertificate(certificate []byte) error { // 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. -func (c *Client) RenewCertificate(cert CertificateResource, revokeOld bool, bundle bool) (CertificateResource, error) { +// For private key reuse the PrivateKey property of the passed in CertificateResource should be non-nil. +func (c *Client) RenewCertificate(cert CertificateResource, bundle bool) (CertificateResource, error) { // Input certificate is PEM encoded. Decode it here as we may need the decoded // cert later on in the renewal process. The input may be a bundle or a single certificate. certificates, err := parsePEMBundle(cert.Certificate) @@ -323,7 +277,7 @@ func (c *Client) RenewCertificate(cert CertificateResource, revokeOld bool, bund // The first step of renewal is to check if we get a renewed cert // directly from the cert URL. - resp, err := http.Get(cert.CertURL) + resp, err := httpGet(cert.CertURL) if err != nil { return CertificateResource{}, err } @@ -342,9 +296,6 @@ func (c *Client) RenewCertificate(cert CertificateResource, revokeOld bool, bund // TODO: Further test if we can actually use the new certificate (Our private key works) if !x509Cert.Equal(serverCert) { logf("[INFO][%s] acme: Server responded with renewed certificate", cert.Domain) - if revokeOld { - c.RevokeCertificate(cert.Certificate) - } issuedCert := pemEncode(derCertificateBytes(serverCertBytes)) // If bundle is true, we want to return a certificate bundle. // To do this, we need the issuer certificate. @@ -368,33 +319,16 @@ func (c *Client) RenewCertificate(cert CertificateResource, revokeOld bool, bund return cert, nil } - var domains []string - newCerts := make([]CertificateResource, 1) - var failures map[string]error - // check for SAN certificate - if len(x509Cert.DNSNames) > 1 { - domains = append(domains, x509Cert.Subject.CommonName) - for _, sanDomain := range x509Cert.DNSNames { - if sanDomain == x509Cert.Subject.CommonName { - continue - } - domains = append(domains, sanDomain) + var privKey crypto.PrivateKey + if cert.PrivateKey != nil { + privKey, err = parsePEMPrivateKey(cert.PrivateKey) + if err != nil { + return CertificateResource{}, err } - newCerts[0], failures = c.ObtainSANCertificate(domains, bundle) - } else { - domains = append(domains, x509Cert.Subject.CommonName) - newCerts, failures = c.ObtainCertificates(domains, bundle) } - if len(failures) > 0 { - return CertificateResource{}, failures[cert.Domain] - } - - if revokeOld { - c.RevokeCertificate(cert.Certificate) - } - - return newCerts[0], nil + newCert, failures := c.ObtainCertificate([]string{cert.Domain}, bundle, privKey) + return newCert, failures[cert.Domain] } // Looks through the challenge combinations to find a solvable match. @@ -447,37 +381,21 @@ func (c *Client) getChallenges(domains []string) ([]authorizationResource, map[s for _, domain := range domains { go func(domain string) { - jsonBytes, err := json.Marshal(authorization{Resource: "new-authz", Identifier: identifier{Type: "dns", Value: domain}}) + authMsg := authorization{Resource: "new-authz", Identifier: identifier{Type: "dns", Value: domain}} + var authz authorization + hdr, err := postJSON(c.jws, c.user.GetRegistration().NewAuthzURL, authMsg, &authz) if err != nil { errc <- domainError{Domain: domain, Error: err} return } - resp, err := c.jws.post(c.user.GetRegistration().NewAuthzURL, jsonBytes) - if err != nil { - errc <- domainError{Domain: domain, Error: err} - return - } - - if resp.StatusCode != http.StatusCreated { - errc <- domainError{Domain: domain, Error: handleHTTPError(resp)} - } - - links := parseLinks(resp.Header["Link"]) + links := parseLinks(hdr["Link"]) if links["next"] == "" { logf("[ERROR][%s] acme: Server did not provide next link to proceed", domain) return } - var authz authorization - decoder := json.NewDecoder(resp.Body) - err = decoder.Decode(&authz) - if err != nil { - errc <- domainError{Domain: domain, Error: err} - } - resp.Body.Close() - - resc <- authorizationResource{Body: authz, NewCertURL: links["next"], AuthURL: resp.Header.Get("Location"), Domain: domain} + resc <- authorizationResource{Body: authz, NewCertURL: links["next"], AuthURL: hdr.Get("Location"), Domain: domain} }(domain) } @@ -505,48 +423,18 @@ func (c *Client) getChallenges(domains []string) ([]authorizationResource, map[s return challenges, failures } -// requestCertificates iterates all granted authorizations, creates RSA private keys and CSRs. -// It then uses these to request a certificate from the CA and returns the list of successfully -// granted certificates. -func (c *Client) requestCertificates(challenges []authorizationResource, bundle bool) ([]CertificateResource, map[string]error) { - resc, errc := make(chan CertificateResource), make(chan domainError) - for _, authz := range challenges { - go func(authz authorizationResource, resc chan CertificateResource, errc chan domainError) { - certRes, err := c.requestCertificate([]authorizationResource{authz}, bundle) - if err != nil { - errc <- domainError{Domain: authz.Domain, Error: err} - } else { - resc <- certRes - } - }(authz, resc, errc) - } - - var certs []CertificateResource - failures := make(map[string]error) - for i := 0; i < len(challenges); i++ { - select { - case res := <-resc: - certs = append(certs, res) - case err := <-errc: - failures[err.Domain] = err.Error - } - } - - close(resc) - close(errc) - - return certs, failures -} - -func (c *Client) requestCertificate(authz []authorizationResource, bundle bool) (CertificateResource, error) { +func (c *Client) requestCertificate(authz []authorizationResource, bundle bool, privKey crypto.PrivateKey) (CertificateResource, error) { if len(authz) == 0 { return CertificateResource{}, errors.New("Passed no authorizations to requestCertificate!") } commonName := authz[0] - privKey, err := generatePrivateKey(rsakey, c.keyBits) - if err != nil { - return CertificateResource{}, err + var err error + if privKey == nil { + privKey, err = generatePrivateKey(rsakey, c.keyBits) + if err != nil { + return CertificateResource{}, err + } } var san []string @@ -584,7 +472,6 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool) switch resp.StatusCode { case 202: case 201: - cert, err := ioutil.ReadAll(limitReader(resp.Body, 1024*1024)) resp.Body.Close() if err != nil { @@ -637,7 +524,7 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool) return CertificateResource{}, handleHTTPError(resp) } - resp, err = http.Get(cerRes.CertURL) + resp, err = httpGet(cerRes.CertURL) if err != nil { return CertificateResource{}, err } @@ -652,7 +539,7 @@ func (c *Client) getIssuerCertificate(url string) ([]byte, error) { return c.issuerCert, nil } - resp, err := http.Get(url) + resp, err := httpGet(url) if err != nil { return nil, err } @@ -690,3 +577,43 @@ func parseLinks(links []string) map[string]string { return linkMap } + +// validate makes the ACME server start validating a +// challenge response, only returning once it is done. +func validate(j *jws, domain, uri string, chlng challenge) error { + var challengeResponse challenge + + hdr, err := postJSON(j, uri, chlng, &challengeResponse) + if err != nil { + return err + } + + // After the path is sent, the ACME server will access our server. + // Repeatedly check the server for an updated status on our request. + for { + switch challengeResponse.Status { + case "valid": + logf("[INFO][%s] The server validated our request", domain) + return nil + case "pending": + break + case "invalid": + return handleChallengeError(challengeResponse) + default: + return errors.New("The server returned an unexpected state.") + } + + ra, err := strconv.Atoi(hdr.Get("Retry-After")) + if err != nil { + // The ACME server MUST return a Retry-After. + // If it doesn't, we'll just poll hard. + ra = 1 + } + time.Sleep(time.Duration(ra) * time.Second) + + hdr, err = getJSON(uri, &challengeResponse) + if err != nil { + return err + } + } +} diff --git a/acme/client_test.go b/acme/client_test.go index 3e97e187..3daaed5f 100644 --- a/acme/client_test.go +++ b/acme/client_test.go @@ -4,8 +4,10 @@ import ( "crypto/rand" "crypto/rsa" "encoding/json" + "net" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -26,8 +28,7 @@ func TestNewClient(t *testing.T) { w.Write(data) })) - caURL, optPort := ts.URL, "1234" - client, err := NewClient(caURL, user, keyBits, optPort) + client, err := NewClient(ts.URL, user, keyBits) if err != nil { t.Fatalf("Could not create client: %v", err) } @@ -46,6 +47,33 @@ func TestNewClient(t *testing.T) { if expected, actual := 2, len(client.solvers); actual != expected { t.Fatalf("Expected %d solver(s), got %d", expected, actual) } +} + +func TestClientOptPort(t *testing.T) { + keyBits := 32 // small value keeps test fast + key, err := rsa.GenerateKey(rand.Reader, keyBits) + if err != nil { + t.Fatal("Could not generate test key:", err) + } + user := mockUser{ + email: "test@test.com", + regres: new(RegistrationResource), + privatekey: key, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := json.Marshal(directory{NewAuthzURL: "http://test", NewCertURL: "http://test", NewRegURL: "http://test", RevokeCertURL: "http://test"}) + w.Write(data) + })) + + optPort := "1234" + optHost := "" + client, err := NewClient(ts.URL, user, keyBits) + if err != nil { + t.Fatalf("Could not create client: %v", err) + } + client.SetHTTPAddress(net.JoinHostPort(optHost, optPort)) + client.SetTLSAddress(net.JoinHostPort(optHost, optPort)) httpSolver, ok := client.solvers["http-01"].(*httpChallenge) if !ok { @@ -54,9 +82,107 @@ func TestNewClient(t *testing.T) { if httpSolver.jws != client.jws { t.Error("Expected http-01 to have same jws as client") } - if httpSolver.optPort != optPort { - t.Errorf("Expected http-01 to have optPort %s but was %s", optPort, httpSolver.optPort) + if httpSolver.port != optPort { + t.Errorf("Expected http-01 to have port %s but was %s", optPort, httpSolver.port) } + if httpSolver.iface != optHost { + t.Errorf("Expected http-01 to have iface %s but was %s", optHost, httpSolver.iface) + } + + httpsSolver, ok := client.solvers["tls-sni-01"].(*tlsSNIChallenge) + if !ok { + t.Fatal("Expected tls-sni-01 solver to be httpChallenge type") + } + if httpsSolver.jws != client.jws { + t.Error("Expected tls-sni-01 to have same jws as client") + } + if httpsSolver.port != optPort { + t.Errorf("Expected tls-sni-01 to have port %s but was %s", optPort, httpSolver.port) + } + if httpsSolver.port != optPort { + t.Errorf("Expected tls-sni-01 to have port %s but was %s", optHost, httpSolver.iface) + } + + // test setting different host + optHost = "127.0.0.1" + client.SetHTTPAddress(net.JoinHostPort(optHost, optPort)) + client.SetTLSAddress(net.JoinHostPort(optHost, optPort)) + + if httpSolver.iface != optHost { + t.Errorf("Expected http-01 to have iface %s but was %s", optHost, httpSolver.iface) + } + if httpsSolver.port != optPort { + t.Errorf("Expected tls-sni-01 to have port %s but was %s", optHost, httpSolver.iface) + } +} + +func TestValidate(t *testing.T) { + var statuses []string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Minimal stub ACME server for validation. + w.Header().Add("Replay-Nonce", "12345") + w.Header().Add("Retry-After", "0") + switch r.Method { + case "HEAD": + case "POST": + st := statuses[0] + statuses = statuses[1:] + writeJSONResponse(w, &challenge{Type: "http-01", Status: st, URI: "http://example.com/", Token: "token"}) + + case "GET": + st := statuses[0] + statuses = statuses[1:] + writeJSONResponse(w, &challenge{Type: "http-01", Status: st, URI: "http://example.com/", Token: "token"}) + + default: + http.Error(w, r.Method, http.StatusMethodNotAllowed) + } + })) + defer ts.Close() + + privKey, _ := generatePrivateKey(rsakey, 512) + j := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} + + tsts := []struct { + name string + statuses []string + want string + }{ + {"POST-unexpected", []string{"weird"}, "unexpected"}, + {"POST-valid", []string{"valid"}, ""}, + {"POST-invalid", []string{"invalid"}, "Error Detail"}, + {"GET-unexpected", []string{"pending", "weird"}, "unexpected"}, + {"GET-valid", []string{"pending", "valid"}, ""}, + {"GET-invalid", []string{"pending", "invalid"}, "Error Detail"}, + } + + for _, tst := range tsts { + statuses = tst.statuses + if err := validate(j, "example.com", ts.URL, challenge{Type: "http-01", Token: "token"}); err == nil && tst.want != "" { + t.Errorf("[%s] validate: got error %v, want something with %q", tst.name, err, tst.want) + } else if err != nil && !strings.Contains(err.Error(), tst.want) { + t.Errorf("[%s] validate: got error %v, want something with %q", tst.name, err, tst.want) + } + } +} + +// writeJSONResponse marshals the body as JSON and writes it to the response. +func writeJSONResponse(w http.ResponseWriter, body interface{}) { + bs, err := json.Marshal(body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(bs); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// stubValidate is like validate, except it does nothing. +func stubValidate(j *jws, domain, uri string, chlng challenge) error { + return nil } type mockUser struct { diff --git a/acme/crypto.go b/acme/crypto.go index 98f5aeb9..9bd199ef 100644 --- a/acme/crypto.go +++ b/acme/crypto.go @@ -45,37 +45,38 @@ const ( ) // GetOCSPForCert takes a PEM encoded cert or cert bundle returning the raw OCSP response, -// the status code of the response and an error, if any. -// This []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. -func GetOCSPForCert(bundle []byte) ([]byte, int, error) { +// 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, OCSPUnknown, err + return nil, nil, err } // We only got one certificate, means we have no issuer certificate - get it. if len(certificates) == 1 { // TODO: build fallback. If this fails, check the remaining array entries. if len(certificates[0].IssuingCertificateURL) == 0 { - return nil, OCSPUnknown, errors.New("no issuing certificate URL") + return nil, nil, errors.New("no issuing certificate URL") } - resp, err := http.Get(certificates[0].IssuingCertificateURL[0]) + resp, err := httpGet(certificates[0].IssuingCertificateURL[0]) if err != nil { - return nil, OCSPUnknown, err + return nil, nil, err } defer resp.Body.Close() issuerBytes, err := ioutil.ReadAll(limitReader(resp.Body, 1024*1024)) if err != nil { - return nil, OCSPUnknown, err + return nil, nil, err } issuerCert, err := x509.ParseCertificate(issuerBytes) if err != nil { - return nil, OCSPUnknown, err + return nil, nil, err } // Insert it into the slice on position 0 @@ -92,30 +93,30 @@ func GetOCSPForCert(bundle []byte) ([]byte, int, error) { // Finally kick off the OCSP request. ocspReq, err := ocsp.CreateRequest(issuedCert, issuerCert, nil) if err != nil { - return nil, OCSPUnknown, err + return nil, nil, err } reader := bytes.NewReader(ocspReq) - req, err := http.Post(issuedCert.OCSPServer[0], "application/ocsp-request", reader) + req, err := httpPost(issuedCert.OCSPServer[0], "application/ocsp-request", reader) if err != nil { - return nil, OCSPUnknown, err + return nil, nil, err } defer req.Body.Close() ocspResBytes, err := ioutil.ReadAll(limitReader(req.Body, 1024*1024)) ocspRes, err := ocsp.ParseResponse(ocspResBytes, issuerCert) if err != nil { - return nil, OCSPUnknown, err + return nil, nil, err } if ocspRes.Certificate == nil { err = ocspRes.CheckSignatureFrom(issuerCert) if err != nil { - return nil, OCSPUnknown, err + return nil, nil, err } } - return ocspResBytes, ocspRes.Status, nil + return ocspResBytes, ocspRes, nil } func getKeyAuthorization(token string, key interface{}) (string, error) { @@ -201,6 +202,19 @@ func parsePEMBundle(bundle []byte) ([]*x509.Certificate, error) { 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(t keyType, keyLength int) (crypto.PrivateKey, error) { switch t { case eckey: diff --git a/acme/http.go b/acme/http.go new file mode 100644 index 00000000..661a0588 --- /dev/null +++ b/acme/http.go @@ -0,0 +1,115 @@ +package acme + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "runtime" + "strings" +) + +// UserAgent, if non-empty, will be tacked onto the User-Agent string in requests. +var UserAgent string + +const ( + // defaultGoUserAgent is the Go HTTP package user agent string. Too + // bad it isn't exported. If it changes, we should update it here, too. + defaultGoUserAgent = "Go-http-client/1.1" + + // ourUserAgent is the User-Agent of this underlying library package. + ourUserAgent = "xenolf-acme" +) + +// httpHead performs a HEAD request with a proper User-Agent string. +// The response body (resp.Body) is already closed when this function returns. +func httpHead(url string) (resp *http.Response, err error) { + req, err := http.NewRequest("HEAD", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", userAgent()) + + client := http.Client{} + resp, err = client.Do(req) + if resp.Body != nil { + resp.Body.Close() + } + return resp, err +} + +// httpPost performs a POST request with a proper User-Agent string. +// Callers should close resp.Body when done reading from it. +func httpPost(url string, bodyType string, body io.Reader) (resp *http.Response, err error) { + req, err := http.NewRequest("POST", url, body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", bodyType) + req.Header.Set("User-Agent", userAgent()) + + client := http.Client{} + return client.Do(req) +} + +// httpGet performs a GET request with a proper User-Agent string. +// Callers should close resp.Body when done reading from it. +func httpGet(url string) (resp *http.Response, err error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", userAgent()) + + client := http.Client{} + return client.Do(req) +} + +// getJSON performs an HTTP GET request and parses the response body +// as JSON, into the provided respBody object. +func getJSON(uri string, respBody interface{}) (http.Header, error) { + resp, err := httpGet(uri) + if err != nil { + return nil, fmt.Errorf("failed to get %q: %v", uri, err) + } + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusBadRequest { + return resp.Header, handleHTTPError(resp) + } + + return resp.Header, json.NewDecoder(resp.Body).Decode(respBody) +} + +// postJSON performs an HTTP POST request and parses the response body +// as JSON, into the provided respBody object. +func postJSON(j *jws, uri string, reqBody, respBody interface{}) (http.Header, error) { + jsonBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, errors.New("Failed to marshal network message...") + } + + resp, err := j.post(uri, jsonBytes) + if err != nil { + return nil, fmt.Errorf("Failed to post JWS message. -> %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusBadRequest { + return resp.Header, handleHTTPError(resp) + } + + if respBody == nil { + return resp.Header, nil + } + + return resp.Header, json.NewDecoder(resp.Body).Decode(respBody) +} + +// userAgent builds and returns the User-Agent string to use in requests. +func userAgent() string { + ua := fmt.Sprintf("%s (%s; %s) %s %s", defaultGoUserAgent, runtime.GOOS, runtime.GOARCH, ourUserAgent, UserAgent) + return strings.TrimSpace(ua) +} diff --git a/acme/http_challenge.go b/acme/http_challenge.go index 36dde87a..69a7ea36 100644 --- a/acme/http_challenge.go +++ b/acme/http_challenge.go @@ -1,133 +1,63 @@ package acme import ( - "encoding/json" - "errors" "fmt" "net" "net/http" "strings" - "time" ) type httpChallenge struct { - jws *jws - optPort string - start chan net.Listener - end chan error + jws *jws + validate validateFunc + iface string + port string } func (s *httpChallenge) Solve(chlng challenge, domain string) error { logf("[INFO][%s] acme: Trying to solve HTTP-01", domain) - s.start = make(chan net.Listener) - s.end = make(chan error) - // Generate the Key Authorization for the challenge keyAuth, err := getKeyAuthorization(chlng.Token, &s.jws.privKey.PublicKey) if err != nil { return err } - go s.startHTTPServer(domain, chlng.Token, keyAuth) - var listener net.Listener - select { - case listener = <-s.start: - break - case err := <-s.end: + // Allow for CLI port override + port := "80" + if s.port != "" { + port = s.port + } + + iface := "" + if s.iface != "" { + iface = s.iface + } + + listener, err := net.Listen("tcp", net.JoinHostPort(iface, port)) + if err != nil { return fmt.Errorf("Could not start HTTP server for challenge -> %v", err) } + defer listener.Close() - // Make sure we properly close the HTTP server before we return - defer func() { - listener.Close() - err = <-s.end - close(s.start) - close(s.end) - }() - - jsonBytes, err := json.Marshal(challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) - if err != nil { - return errors.New("Failed to marshal network message...") - } - - // Tell the server we handle HTTP-01 - resp, err := s.jws.post(chlng.URI, jsonBytes) - if err != nil { - return fmt.Errorf("Failed to post JWS message. -> %v", err) - } - - // After the path is sent, the ACME server will access our server. - // Repeatedly check the server for an updated status on our request. - var challengeResponse challenge -Loop: - for { - if resp.StatusCode >= http.StatusBadRequest { - return handleHTTPError(resp) - } - - err = json.NewDecoder(resp.Body).Decode(&challengeResponse) - resp.Body.Close() - if err != nil { - return err - } - - switch challengeResponse.Status { - case "valid": - logf("[INFO][%s] The server validated our request", domain) - break Loop - case "pending": - break - case "invalid": - return handleChallengeError(challengeResponse) - default: - return errors.New("The server returned an unexpected state.") - } - - time.Sleep(1 * time.Second) - resp, err = http.Get(chlng.URI) - } - - return nil -} - -func (s *httpChallenge) startHTTPServer(domain string, token string, keyAuth string) { - - // Allow for CLI port override - port := ":80" - if s.optPort != "" { - port = ":" + s.optPort - } - - listener, err := net.Listen("tcp", domain+port) - if err != nil { - // if the domain:port bind failed, fall back to :port bind and try that instead. - listener, err = net.Listen("tcp", port) - if err != nil { - s.end <- err - } - } - // Signal successfull start - s.start <- listener - - path := "/.well-known/acme-challenge/" + token + path := "/.well-known/acme-challenge/" + chlng.Token // The handler validates the HOST header and request type. // For validation it then writes the token the server returned with the challenge - http.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + mux := http.NewServeMux() + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.Host, domain) && r.Method == "GET" { w.Header().Add("Content-Type", "text/plain") w.Write([]byte(keyAuth)) - logf("[INFO] Served key authentication") + logf("[INFO][%s] Served key authentication", domain) } else { logf("[INFO] Received request for domain %s with method %s", r.Host, r.Method) w.Write([]byte("TEST")) } }) - http.Serve(listener, nil) + go http.Serve(listener, mux) - // Signal that the server was shut down - s.end <- nil + return s.validate(s.jws, domain, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) } diff --git a/acme/http_challenge_test.go b/acme/http_challenge_test.go index 3b9d4699..9ffb27f8 100644 --- a/acme/http_challenge_test.go +++ b/acme/http_challenge_test.go @@ -2,263 +2,55 @@ package acme import ( "crypto/rsa" - "crypto/tls" - "encoding/json" "io/ioutil" - "net/http" - "net/http/httptest" - "regexp" "strings" "testing" - - "github.com/square/go-jose" ) -func TestHTTPNonRootBind(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 128) - jws := &jws{privKey: privKey.(*rsa.PrivateKey)} - - solver := &httpChallenge{jws: jws} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: "localhost:4000", Token: "http1"} - - // validate error on non-root bind to 80 - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("BIND: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "Could not start HTTP server for challenge -> listen tcp :80: bind: permission denied" - if err.Error() != expectedError { - t.Errorf("Expected error \"%s\" but instead got \"%s\"", expectedError, err.Error()) - } - } -} - -func TestHTTPShortRSA(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 128) - jws := &jws{privKey: privKey.(*rsa.PrivateKey), nonces: []string{"test1", "test2"}} - - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: "http://localhost:4000", Token: "http2"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "Failed to post JWS message. -> crypto/rsa: message too long for RSA public key size" - if err.Error() != expectedError { - t.Errorf("Expected error %s but instead got %s", expectedError, err.Error()) - } - } -} - -func TestHTTPConnectionRefusal(t *testing.T) { +func TestHTTPChallenge(t *testing.T) { privKey, _ := generatePrivateKey(rsakey, 512) - jws := &jws{privKey: privKey.(*rsa.PrivateKey), nonces: []string{"test1", "test2"}} - - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: "http://localhost:4000", Token: "http3"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } else { - reg := "/Failed to post JWS message\\. -> Post http:\\/\\/localhost:4000: dial tcp 127\\.0\\.0\\.1:4000: (getsockopt: )?connection refused/g" - test2 := "Failed to post JWS message. -> Post http://localhost:4000: dial tcp 127.0.0.1:4000: connection refused" - r, _ := regexp.Compile(reg) - if r.MatchString(err.Error()) && r.MatchString(test2) { - t.Errorf("Expected \"%s\" to match %s", err.Error(), reg) - } - } -} - -func TestHTTPUnexpectedServerState(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Replay-Nonce", "12345") - w.Write([]byte("{\"type\":\"http01\",\"status\":\"what\",\"uri\":\"http://some.url\",\"token\":\"http4\"}")) - })) - - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http4"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "The server returned an unexpected state." - if err.Error() != expectedError { - t.Errorf("Expected error %s but instead got %s", expectedError, err.Error()) - } - } -} - -func TestHTTPChallengeServerUnexpectedDomain(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - jws := &jws{privKey: privKey.(*rsa.PrivateKey)} - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "POST" { - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - client := &http.Client{Transport: tr} - req, _ := client.Get("https://localhost:23456/.well-known/acme-challenge/" + "htto5") - reqBytes, _ := ioutil.ReadAll(req.Body) - if string(reqBytes) != "TEST" { - t.Error("Expected http01 server to return string TEST on unexpected domain.") - } - } - - w.Header().Add("Replay-Nonce", "12345") - w.Write([]byte("{\"type\":\"http01\",\"status\":\"invalid\",\"uri\":\"http://some.url\",\"token\":\"http5\"}")) - })) - - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http5"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } -} - -func TestHTTPServerError(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "HEAD" { - w.Header().Add("Replay-Nonce", "12345") - } else { - w.WriteHeader(http.StatusInternalServerError) - w.Header().Add("Replay-Nonce", "12345") - w.Write([]byte("{\"type\":\"urn:acme:error:unauthorized\",\"detail\":\"Error creating new authz :: Syntax error\"}")) - } - })) - - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http6"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "acme: Error 500 - urn:acme:error:unauthorized - Error creating new authz :: Syntax error" - if err.Error() != expectedError { - t.Errorf("Expected error |%s| but instead got |%s|", expectedError, err.Error()) - } - } -} - -func TestHTTPInvalidServerState(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Replay-Nonce", "12345") - w.Write([]byte("{\"type\":\"http01\",\"status\":\"invalid\",\"uri\":\"http://some.url\",\"token\":\"http7\"}")) - })) - - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http7"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "acme: Error 0 - - \nError Detail:\n" - if err.Error() != expectedError { - t.Errorf("Expected error |%s| but instead got |%s|", expectedError, err.Error()) - } - } -} - -func TestHTTPValidServerResponse(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Replay-Nonce", "12345") - w.Write([]byte("{\"type\":\"http01\",\"status\":\"valid\",\"uri\":\"http://some.url\",\"token\":\"http8\"}")) - })) - - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http8"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err != nil { - t.Errorf("VALID: Expected Solve to return no error but the error was -> %v", err) - } -} - -func TestHTTPValidFull(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - - ts := httptest.NewServer(nil) - - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &httpChallenge{jws: jws, optPort: "23457"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http9"} - - // Validate server on port 23456 which responds appropriately - ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request challenge - w.Header().Add("Replay-Nonce", "12345") - - if r.Method == "HEAD" { - return - } - - clientJws, _ := ioutil.ReadAll(r.Body) - j, err := jose.ParseSigned(string(clientJws)) + j := &jws{privKey: privKey.(*rsa.PrivateKey)} + clientChallenge := challenge{Type: "http-01", Token: "http1"} + mockValidate := func(_ *jws, _, _ string, chlng challenge) error { + uri := "http://localhost:23457/.well-known/acme-challenge/" + chlng.Token + resp, err := httpGet(uri) if err != nil { - t.Errorf("Client sent invalid JWS to the server.\n\t%v", err) - return - } - output, err := j.Verify(&privKey.(*rsa.PrivateKey).PublicKey) - if err != nil { - t.Errorf("Unable to verify client data -> %v", err) - } - json.Unmarshal(output, &request) - - transport := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} - client := &http.Client{Transport: transport} - - reqURL := "http://localhost:23457/.well-known/acme-challenge/" + clientChallenge.Token - t.Logf("Request URL is: %s", reqURL) - req, err := http.NewRequest("GET", reqURL, nil) - if err != nil { - t.Error(err) - } - req.Host = "127.0.0.1" - resp, err := client.Do(req) - if err != nil { - t.Errorf("Expected the solver to listen on port 23457 -> %v", err) + return err } defer resp.Body.Close() - body, _ := ioutil.ReadAll(resp.Body) + 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 resp.Header.Get("Content-Type") != "text/plain" { - t.Errorf("Expected server to respond with content type text/plain.") + if bodyStr != chlng.KeyAuthorization { + t.Errorf("Get(%q) Body: got %q, want %q", uri, bodyStr, chlng.KeyAuthorization) } - tokenRegex := regexp.MustCompile("^[\\w-]{43}$") - parts := strings.Split(bodyStr, ".") + return nil + } + solver := &httpChallenge{jws: j, validate: mockValidate, port: "23457"} - if len(parts) != 2 { - t.Errorf("Expected server token to be a composite of two strings, seperated by a dot") - } - - if parts[0] != clientChallenge.Token { - t.Errorf("Expected the first part of the server token to be the challenge token.") - } - - if !tokenRegex.MatchString(parts[1]) { - t.Errorf("Expected the second part of the server token to be a properly formatted key authorization") - } - - valid := challenge{Type: "http01", Status: "valid", URI: ts.URL, Token: "1234567812"} - jsonBytes, _ := json.Marshal(&valid) - w.Write(jsonBytes) - }) - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err != nil { - t.Errorf("VALID: Expected Solve to return no error but the error was -> %v", err) + if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil { + t.Errorf("Solve error: got %v, want nil", err) + } +} + +func TestHTTPChallengeInvalidPort(t *testing.T) { + privKey, _ := generatePrivateKey(rsakey, 128) + j := &jws{privKey: privKey.(*rsa.PrivateKey)} + clientChallenge := challenge{Type: "http-01", Token: "http2"} + solver := &httpChallenge{jws: j, validate: stubValidate, port: "123456"} + + if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil { + t.Errorf("Solve error: got %v, want error", err) + } else if want := "invalid port 123456"; !strings.HasSuffix(err.Error(), want) { + t.Errorf("Solve error: got %q, want suffix %q", err.Error(), want) } } diff --git a/acme/http_test.go b/acme/http_test.go new file mode 100644 index 00000000..3e04e950 --- /dev/null +++ b/acme/http_test.go @@ -0,0 +1,88 @@ +package acme + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHTTPHeadUserAgent(t *testing.T) { + var ua string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ua = r.Header.Get("User-Agent") + })) + defer ts.Close() + + _, err := httpHead(ts.URL) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(ua, ourUserAgent) { + t.Errorf("Expected User-Agent to contain '%s', got: '%s'", ourUserAgent, ua) + } +} + +func TestHTTPGetUserAgent(t *testing.T) { + var ua string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ua = r.Header.Get("User-Agent") + })) + defer ts.Close() + + res, err := httpGet(ts.URL) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + + if !strings.Contains(ua, ourUserAgent) { + t.Errorf("Expected User-Agent to contain '%s', got: '%s'", ourUserAgent, ua) + } +} + +func TestHTTPPostUserAgent(t *testing.T) { + var ua string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ua = r.Header.Get("User-Agent") + })) + defer ts.Close() + + res, err := httpPost(ts.URL, "text/plain", strings.NewReader("falalalala")) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + + if !strings.Contains(ua, ourUserAgent) { + t.Errorf("Expected User-Agent to contain '%s', got: '%s'", ourUserAgent, ua) + } +} + +func TestUserAgent(t *testing.T) { + ua := userAgent() + + if !strings.Contains(ua, defaultGoUserAgent) { + t.Errorf("Expected UA to contain %s, got '%s'", defaultGoUserAgent, ua) + } + if !strings.Contains(ua, ourUserAgent) { + t.Errorf("Expected UA to contain %s, got '%s'", ourUserAgent, ua) + } + if strings.HasSuffix(ua, " ") { + t.Errorf("UA should not have trailing spaces; got '%s'", ua) + } + + // customize the UA by appending a value + UserAgent = "MyApp/1.2.3" + ua = userAgent() + if !strings.Contains(ua, defaultGoUserAgent) { + t.Errorf("Expected UA to contain %s, got '%s'", defaultGoUserAgent, ua) + } + if !strings.Contains(ua, ourUserAgent) { + t.Errorf("Expected UA to contain %s, got '%s'", ourUserAgent, ua) + } + if !strings.Contains(ua, UserAgent) { + t.Errorf("Expected custom UA to contain %s, got '%s'", UserAgent, ua) + } +} diff --git a/acme/jws.go b/acme/jws.go index ede8eff4..b676fe39 100644 --- a/acme/jws.go +++ b/acme/jws.go @@ -35,7 +35,7 @@ func (j *jws) post(url string, content []byte) (*http.Response, error) { return nil, err } - resp, err := http.Post(url, "application/jose+json", bytes.NewBuffer([]byte(signedContent.FullSerialize()))) + resp, err := httpPost(url, "application/jose+json", bytes.NewBuffer([]byte(signedContent.FullSerialize()))) if err != nil { return nil, err } @@ -71,7 +71,7 @@ func (j *jws) getNonceFromResponse(resp *http.Response) error { } func (j *jws) getNonce() error { - resp, err := http.Head(j.directoryURL) + resp, err := httpHead(j.directoryURL) if err != nil { return err } diff --git a/acme/tls_sni_challenge.go b/acme/tls_sni_challenge.go index 5a6763c6..e2511ad3 100644 --- a/acme/tls_sni_challenge.go +++ b/acme/tls_sni_challenge.go @@ -5,19 +5,16 @@ import ( "crypto/sha256" "crypto/tls" "encoding/hex" - "encoding/json" - "errors" "fmt" "net" "net/http" - "time" ) type tlsSNIChallenge struct { - jws *jws - optPort string - start chan net.Listener - end chan error + jws *jws + validate validateFunc + iface string + port string } func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { @@ -26,80 +23,40 @@ func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { logf("[INFO][%s] acme: Trying to solve TLS-SNI-01", domain) - t.start = make(chan net.Listener) - t.end = make(chan error) - // Generate the Key Authorization for the challenge keyAuth, err := getKeyAuthorization(chlng.Token, &t.jws.privKey.PublicKey) if err != nil { return err } - certificate, err := t.generateCertificate(keyAuth) + cert, err := t.generateCertificate(keyAuth) if err != nil { return err } - go t.startSNITLSServer(certificate) - var listener net.Listener - select { - case listener = <-t.start: - break - case err := <-t.end: + // Allow for CLI port override + port := "443" + if t.port != "" { + port = t.port + } + + iface := "" + if t.iface != "" { + iface = t.iface + } + + tlsConf := new(tls.Config) + tlsConf.Certificates = []tls.Certificate{cert} + + listener, err := tls.Listen("tcp", net.JoinHostPort(iface, port), tlsConf) + if err != nil { return fmt.Errorf("Could not start HTTPS server for challenge -> %v", err) } + defer listener.Close() - // Make sure we properly close the HTTP server before we return - defer func() { - listener.Close() - err = <-t.end - close(t.start) - close(t.end) - }() + go http.Serve(listener, nil) - jsonBytes, err := json.Marshal(challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) - if err != nil { - return errors.New("Failed to marshal network message...") - } - - // Tell the server we handle TLS-SNI-01 - resp, err := t.jws.post(chlng.URI, jsonBytes) - if err != nil { - return fmt.Errorf("Failed to post JWS message. -> %v", err) - } - - // After the path is sent, the ACME server will access our server. - // Repeatedly check the server for an updated status on our request. - var challengeResponse challenge -Loop: - for { - if resp.StatusCode >= http.StatusBadRequest { - return handleHTTPError(resp) - } - - err = json.NewDecoder(resp.Body).Decode(&challengeResponse) - resp.Body.Close() - if err != nil { - return err - } - - switch challengeResponse.Status { - case "valid": - logf("[INFO][%s] The server validated our request", domain) - break Loop - case "pending": - break - case "invalid": - return handleChallengeError(challengeResponse) - default: - return errors.New("The server returned an unexpected state.") - } - - time.Sleep(1 * time.Second) - resp, err = http.Get(chlng.URI) - } - - return nil + return t.validate(t.jws, domain, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) } func (t *tlsSNIChallenge) generateCertificate(keyAuth string) (tls.Certificate, error) { @@ -128,26 +85,3 @@ func (t *tlsSNIChallenge) generateCertificate(keyAuth string) (tls.Certificate, return certificate, nil } - -func (t *tlsSNIChallenge) startSNITLSServer(cert tls.Certificate) { - - // Allow for CLI port override - port := ":443" - if t.optPort != "" { - port = ":" + t.optPort - } - - tlsConf := new(tls.Config) - tlsConf.Certificates = []tls.Certificate{cert} - - tlsListener, err := tls.Listen("tcp", port, tlsConf) - if err != nil { - t.end <- err - } - // Signal successfull start - t.start <- tlsListener - - http.Serve(tlsListener, nil) - - t.end <- nil -} diff --git a/acme/tls_sni_challenge_test.go b/acme/tls_sni_challenge_test.go index 41c3f9db..f2350303 100644 --- a/acme/tls_sni_challenge_test.go +++ b/acme/tls_sni_challenge_test.go @@ -5,61 +5,17 @@ import ( "crypto/sha256" "crypto/tls" "encoding/hex" - "encoding/json" "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" + "strings" "testing" - - "github.com/square/go-jose" ) -func TestTLSSNINonRootBind(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 128) - jws := &jws{privKey: privKey.(*rsa.PrivateKey)} - - solver := &tlsSNIChallenge{jws: jws} - clientChallenge := challenge{Type: "tls-sni-01", Status: "pending", URI: "localhost:4000", Token: "tls1"} - - // validate error on non-root bind to 443 - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("BIND: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "Could not start HTTPS server for challenge -> listen tcp :443: bind: permission denied" - if err.Error() != expectedError { - t.Errorf("Expected error \"%s\" but instead got \"%s\"", expectedError, err.Error()) - } - } -} - -func TestTLSSNI(t *testing.T) { +func TestTLSSNIChallenge(t *testing.T) { privKey, _ := generatePrivateKey(rsakey, 512) - optPort := "5001" - - ts := httptest.NewServer(nil) - - ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request challenge - w.Header().Add("Replay-Nonce", "12345") - - if r.Method == "HEAD" { - return - } - - clientJws, _ := ioutil.ReadAll(r.Body) - j, err := jose.ParseSigned(string(clientJws)) - if err != nil { - t.Errorf("Client sent invalid JWS to the server.\n\t%v", err) - return - } - output, err := j.Verify(&privKey.(*rsa.PrivateKey).PublicKey) - if err != nil { - t.Errorf("Unable to verify client data -> %v", err) - } - json.Unmarshal(output, &request) - - conn, err := tls.Dial("tcp", "localhost:"+optPort, &tls.Config{ + j := &jws{privKey: privKey.(*rsa.PrivateKey)} + clientChallenge := challenge{Type: "tls-sni-01", Token: "tlssni1"} + mockValidate := func(_ *jws, _, _ string, chlng challenge) error { + conn, err := tls.Dial("tcp", "localhost:23457", &tls.Config{ InsecureSkipVerify: true, }) if err != nil { @@ -77,7 +33,7 @@ func TestTLSSNI(t *testing.T) { t.Errorf("Expected the challenge certificate to have exactly one DNSNames entry but had %d", count) } - zBytes := sha256.Sum256([]byte(request.KeyAuthorization)) + zBytes := sha256.Sum256([]byte(chlng.KeyAuthorization)) z := hex.EncodeToString(zBytes[:sha256.Size]) domain := fmt.Sprintf("%s.%s.acme.invalid", z[:32], z[32:]) @@ -85,16 +41,24 @@ func TestTLSSNI(t *testing.T) { t.Errorf("Expected the challenge certificate DNSName to match %s but was %s", domain, remoteCert.DNSNames[0]) } - valid := challenge{Type: "tls-sni-01", Status: "valid", URI: ts.URL, Token: "tls1"} - jsonBytes, _ := json.Marshal(&valid) - w.Write(jsonBytes) - }) + return nil + } + solver := &tlsSNIChallenge{jws: j, validate: mockValidate, port: "23457"} - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &tlsSNIChallenge{jws: jws, optPort: optPort} - clientChallenge := challenge{Type: "tls-sni-01", Status: "pending", URI: ts.URL, Token: "tls1"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err != nil { - t.Error("UNEXPECTED: Expected Solve to return no error but the error was %s.", err.Error()) + if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil { + t.Errorf("Solve error: got %v, want nil", err) + } +} + +func TestTLSSNIChallengeInvalidPort(t *testing.T) { + privKey, _ := generatePrivateKey(rsakey, 128) + j := &jws{privKey: privKey.(*rsa.PrivateKey)} + clientChallenge := challenge{Type: "tls-sni-01", Token: "tlssni2"} + solver := &tlsSNIChallenge{jws: j, validate: stubValidate, port: "123456"} + + if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil { + t.Errorf("Solve error: got %v, want error", err) + } else if want := "invalid port 123456"; !strings.HasSuffix(err.Error(), want) { + t.Errorf("Solve error: got %q, want suffix %q", err.Error(), want) } } diff --git a/cli.go b/cli.go index 2d6b2c9c..c07cf716 100644 --- a/cli.go +++ b/cli.go @@ -4,8 +4,10 @@ import ( "log" "os" "path" + "strings" "github.com/codegangsta/cli" + "github.com/xenolf/lego/acme" ) // Logger is used to log errors; if nil, the default log.Logger is used. @@ -19,12 +21,21 @@ func logger() *log.Logger { return Logger } -func main() { +var gittag string +func main() { app := cli.NewApp() app.Name = "lego" app.Usage = "Let's encrypt client to go!" - app.Version = "0.1.0" + + version := "0.2.0" + if strings.HasPrefix(gittag, "v") { + version = gittag + } + + app.Version = version + + acme.UserAgent = "lego/" + app.Version cwd, err := os.Getwd() if err != nil { @@ -53,6 +64,10 @@ func main() { 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.", + }, }, }, } @@ -81,9 +96,17 @@ func main() { 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\", \"tls-sni-01\".", + }, cli.StringFlag{ - Name: "port", - Usage: "Challenges will use this port to listen on. Please make sure to forward port 80 and 443 to this port on your machine. Otherwise use setcap on the binary", + 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", }, } diff --git a/cli_handlers.go b/cli_handlers.go index 037b168c..613cd6e8 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -34,11 +34,23 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { //TODO: move to account struct? Currently MUST pass email. acc := NewAccount(c.GlobalString("email"), conf) - client, err := acme.NewClient(c.GlobalString("server"), acc, conf.RsaBits(), conf.OptPort()) + client, err := acme.NewClient(c.GlobalString("server"), acc, conf.RsaBits()) if err != nil { logger().Fatalf("Could not create client: %s", err.Error()) } + if len(c.GlobalStringSlice("exclude")) > 0 { + client.ExcludeChallenges(conf.ExcludedSolvers()) + } + + if c.GlobalIsSet("http") { + client.SetHTTPAddress(c.GlobalString("http")) + } + + if c.GlobalIsSet("tls") { + client.SetTLSAddress(c.GlobalString("tls")) + } + return conf, acc, client } @@ -126,7 +138,7 @@ func run(c *cli.Context) { logger().Fatal("Please specify --domains or -d") } - cert, failures := client.ObtainSANCertificate(c.GlobalStringSlice("domains"), true) + cert, failures := client.ObtainCertificate(c.GlobalStringSlice("domains"), true, nil) if len(failures) > 0 { for k, v := range failures { logger().Printf("[%s] Could not obtain certificates\n\t%s", k, v.Error()) @@ -202,11 +214,6 @@ func renew(c *cli.Context) { } } - keyBytes, err := ioutil.ReadFile(privPath) - if err != nil { - logger().Fatalf("Error while loading the private key for domain %s\n\t%s", domain, err.Error()) - } - metaBytes, err := ioutil.ReadFile(metaPath) if err != nil { logger().Fatalf("Error while loading the meta data for domain %s\n\t%s", domain, err.Error()) @@ -218,10 +225,17 @@ func renew(c *cli.Context) { logger().Fatalf("Error while marshalling the meta data for domain %s\n\t%s", domain, err.Error()) } - certRes.PrivateKey = keyBytes + if c.Bool("reuse-key") { + keyBytes, err := ioutil.ReadFile(privPath) + if err != nil { + logger().Fatalf("Error while loading the private key for domain %s\n\t%s", domain, err.Error()) + } + certRes.PrivateKey = keyBytes + } + certRes.Certificate = certBytes - newCert, err := client.RenewCertificate(certRes, true, true) + newCert, err := client.RenewCertificate(certRes, true) if err != nil { logger().Fatalf("%s", err.Error()) } diff --git a/configuration.go b/configuration.go index e96896aa..a47f1011 100644 --- a/configuration.go +++ b/configuration.go @@ -24,8 +24,8 @@ func (c *Configuration) RsaBits() int { return c.context.GlobalInt("rsa-key-size") } -func (c *Configuration) OptPort() string { - return c.context.GlobalString("port") +func (c *Configuration) ExcludedSolvers() []string { + return c.context.GlobalStringSlice("exclude") } // ServerPath returns the OS dependent path to the data for a specific CA