Merge branch 'refactor-client'

This commit is contained in:
xenolf 2016-01-09 02:16:25 +01:00
commit 1369fa9f3c
15 changed files with 707 additions and 749 deletions

View file

@ -2,8 +2,29 @@
## [Unreleased] ## [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: ### Fixed:
- CLI: Fix logic using the --days parameter - CLI: Fix logic using the `--days` parameter for renew
## [0.1.1] - 2015-12-18 ## [0.1.1] - 2015-12-18

View file

@ -38,22 +38,26 @@ Current features:
Please keep in mind that CLI switches and APIs are still subject to change. 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 #### Sudo
The CLI does not require root permissions but needs to bind to port 80 and 443 for certain challenges. 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: To run the CLI without sudo, you have two options:
- Use setcap 'cap_net_bind_service=+ep' /path/to/program - 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 #### Port Usage
By default lego assumes it is able to bind to ports 80 and 443 to solve challenges. 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 If this is not possible in your environment, you can use the `--http` and `--tls` options to instruct
lego to listen on that port for any incoming challenges. 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. - 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. - 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. 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...] ./lego [global options] command [command options] [arguments...]
VERSION: VERSION:
0.1.0 0.2.0
COMMANDS: COMMANDS:
run Register an account, then create and install a certificate 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. --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. --email, -m Email used for registration and recovery contact.
--rsa-key-size, -B "2048" Size of the RSA key. --rsa-key-size, -B "2048" Size of the RSA key.
--path "${CWD}" Directory to use for storing the data --path "${CWD}/.lego" 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 --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 --help, -h show help
--version, -v print the version --version, -v print the version
@ -141,14 +147,18 @@ myUser := MyUser{
// A client facilitates communication with the CA server. This CA URL is // 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. // 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 client, err := acme.NewClient("http://192.168.99.100:4000", &myUser, rsaKeySize)
// 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")
if err != nil { if err != nil {
log.Fatal(err) 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 // New users will need to register; be sure to save it
reg, err := client.Register() reg, err := client.Register()
if err != nil { if err != nil {
@ -166,7 +176,7 @@ if err != nil {
// The acme library takes care of completing the challenges to obtain the certificate(s). // 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. // Of course, the hostnames must resolve to this machine or it will fail.
bundle := false bundle := false
certificates, err := client.ObtainCertificates([]string{"mydomain.com"}, bundle) certificates, err := client.ObtainCertificate([]string{"mydomain.com"}, bundle, nil)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }

View file

@ -1,6 +1,7 @@
package acme package acme
import ( import (
"crypto"
"crypto/rsa" "crypto/rsa"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
@ -9,15 +10,17 @@ import (
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"log" "log"
"net/http" "net"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
) )
// Logger is an optional custom logger. var (
var Logger *log.Logger // Logger is an optional custom logger.
Logger *log.Logger
)
// logf writes a log entry. It uses Logger if not // logf writes a log entry. It uses Logger if not
// nil, otherwise it uses the default log.Logger. // nil, otherwise it uses the default log.Logger.
@ -42,6 +45,8 @@ type solver interface {
Solve(challenge challenge, domain string) error 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 // Client is the user-friendy way to ACME
type Client struct { type Client struct {
directory directory directory directory
@ -52,13 +57,10 @@ type Client struct {
solvers map[string]solver 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 // 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 // generate private keys for certificates of size keyBits.
// type requires it, the client will open a port at optPort to solve the challenge. func NewClient(caDirURL string, user User, keyBits int) (*Client, error) {
// 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) {
privKey := user.GetPrivateKey() privKey := user.GetPrivateKey()
if privKey == nil { if privKey == nil {
return nil, errors.New("private key was 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) 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 var dir directory
err = json.NewDecoder(dirResp.Body).Decode(&dir) if _, err := getJSON(caDirURL, &dir); err != nil {
if err != nil { return nil, fmt.Errorf("get directory at '%s': %v", caDirURL, err)
return nil, fmt.Errorf("decode directory: %v", err)
} }
if dir.NewRegURL == "" { 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 // Add all available solvers with the right index as per ACME
// spec to this map. Otherwise they won`t be found. // spec to this map. Otherwise they won`t be found.
solvers := make(map[string]solver) solvers := make(map[string]solver)
solvers["http-01"] = &httpChallenge{jws: jws, optPort: optPort} solvers["http-01"] = &httpChallenge{jws: jws, validate: validate}
solvers["tls-sni-01"] = &tlsSNIChallenge{jws: jws, optPort: optPort} solvers["tls-sni-01"] = &tlsSNIChallenge{jws: jws, validate: validate}
return &Client{directory: dir, user: user, jws: jws, keyBits: keyBits, solvers: solvers}, nil 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. // Register the current account to the ACME server.
func (c *Client) Register() (*RegistrationResource, error) { func (c *Client) Register() (*RegistrationResource, error) {
if c == nil || c.user == nil { if c == nil || c.user == nil {
@ -121,32 +160,16 @@ func (c *Client) Register() (*RegistrationResource, error) {
regMsg.Contact = []string{} 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 var serverReg Registration
decoder := json.NewDecoder(resp.Body) hdr, err := postJSON(c.jws, c.directory.NewRegURL, regMsg, &serverReg)
err = decoder.Decode(&serverReg)
if err != nil { if err != nil {
return nil, err return nil, err
} }
reg := &RegistrationResource{Body: serverReg} reg := &RegistrationResource{Body: serverReg}
links := parseLinks(resp.Header["Link"]) links := parseLinks(hdr["Link"])
reg.URI = resp.Header.Get("Location") reg.URI = hdr.Get("Location")
if links["terms-of-service"] != "" { if links["terms-of-service"] != "" {
reg.TosURL = 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 { func (c *Client) AgreeToTOS() error {
c.user.GetRegistration().Body.Agreement = c.user.GetRegistration().TosURL c.user.GetRegistration().Body.Agreement = c.user.GetRegistration().TosURL
c.user.GetRegistration().Body.Resource = "reg" c.user.GetRegistration().Body.Resource = "reg"
jsonBytes, err := json.Marshal(&c.user.GetRegistration().Body) _, err := postJSON(c.jws, c.user.GetRegistration().URI, c.user.GetRegistration().Body, nil)
if err != nil { return err
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
} }
// ObtainCertificates tries to obtain certificates from the CA server // ObtainCertificate tries to obtain a single certificate using all domains passed into it.
// 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.
// The first domain in domains is used for the CommonName field of the certificate, all other // 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 // If bundle is true, the []byte contains both the issuer certificate and
// your issued certificate as a bundle. // your issued certificate as a bundle.
// This function will never return a partial certificate. If one domain in the list fails, // This function will never return a partial certificate. If one domain in the list fails,
// the whole certificate will fail. // 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 { if bundle {
logf("[INFO][%s] acme: Obtaining bundled SAN certificate", strings.Join(domains, ", ")) logf("[INFO][%s] acme: Obtaining bundled SAN certificate", strings.Join(domains, ", "))
} else { } 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, ", ")) 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 { if err != nil {
for _, chln := range challenges { for _, chln := range challenges {
failures[chln.Domain] = err failures[chln.Domain] = err
@ -279,22 +246,8 @@ func (c *Client) RevokeCertificate(certificate []byte) error {
encodedCert := base64.URLEncoding.EncodeToString(x509Cert.Raw) encodedCert := base64.URLEncoding.EncodeToString(x509Cert.Raw)
jsonBytes, err := json.Marshal(revokeCertMessage{Resource: "revoke-cert", Certificate: encodedCert}) _, err = postJSON(c.jws, c.directory.RevokeCertURL, revokeCertMessage{Resource: "revoke-cert", Certificate: encodedCert}, nil)
if err != nil { return err
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
} }
// RenewCertificate takes a CertificateResource and tries to renew the certificate. // 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. // 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 // If bundle is true, the []byte contains both the issuer certificate and
// your issued certificate as a bundle. // 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 // 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. // cert later on in the renewal process. The input may be a bundle or a single certificate.
certificates, err := parsePEMBundle(cert.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 // The first step of renewal is to check if we get a renewed cert
// directly from the cert URL. // directly from the cert URL.
resp, err := http.Get(cert.CertURL) resp, err := httpGet(cert.CertURL)
if err != nil { if err != nil {
return CertificateResource{}, err 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) // TODO: Further test if we can actually use the new certificate (Our private key works)
if !x509Cert.Equal(serverCert) { if !x509Cert.Equal(serverCert) {
logf("[INFO][%s] acme: Server responded with renewed certificate", cert.Domain) logf("[INFO][%s] acme: Server responded with renewed certificate", cert.Domain)
if revokeOld {
c.RevokeCertificate(cert.Certificate)
}
issuedCert := pemEncode(derCertificateBytes(serverCertBytes)) issuedCert := pemEncode(derCertificateBytes(serverCertBytes))
// If bundle is true, we want to return a certificate bundle. // If bundle is true, we want to return a certificate bundle.
// To do this, we need the issuer certificate. // To do this, we need the issuer certificate.
@ -368,33 +319,16 @@ func (c *Client) RenewCertificate(cert CertificateResource, revokeOld bool, bund
return cert, nil return cert, nil
} }
var domains []string var privKey crypto.PrivateKey
newCerts := make([]CertificateResource, 1) if cert.PrivateKey != nil {
var failures map[string]error privKey, err = parsePEMPrivateKey(cert.PrivateKey)
// check for SAN certificate if err != nil {
if len(x509Cert.DNSNames) > 1 { return CertificateResource{}, err
domains = append(domains, x509Cert.Subject.CommonName)
for _, sanDomain := range x509Cert.DNSNames {
if sanDomain == x509Cert.Subject.CommonName {
continue
}
domains = append(domains, sanDomain)
} }
newCerts[0], failures = c.ObtainSANCertificate(domains, bundle)
} else {
domains = append(domains, x509Cert.Subject.CommonName)
newCerts, failures = c.ObtainCertificates(domains, bundle)
} }
if len(failures) > 0 { newCert, failures := c.ObtainCertificate([]string{cert.Domain}, bundle, privKey)
return CertificateResource{}, failures[cert.Domain] return newCert, failures[cert.Domain]
}
if revokeOld {
c.RevokeCertificate(cert.Certificate)
}
return newCerts[0], nil
} }
// Looks through the challenge combinations to find a solvable match. // 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 { for _, domain := range domains {
go func(domain string) { 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 { if err != nil {
errc <- domainError{Domain: domain, Error: err} errc <- domainError{Domain: domain, Error: err}
return return
} }
resp, err := c.jws.post(c.user.GetRegistration().NewAuthzURL, jsonBytes) links := parseLinks(hdr["Link"])
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"])
if links["next"] == "" { if links["next"] == "" {
logf("[ERROR][%s] acme: Server did not provide next link to proceed", domain) logf("[ERROR][%s] acme: Server did not provide next link to proceed", domain)
return return
} }
var authz authorization resc <- authorizationResource{Body: authz, NewCertURL: links["next"], AuthURL: hdr.Get("Location"), Domain: domain}
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}
}(domain) }(domain)
} }
@ -505,48 +423,18 @@ func (c *Client) getChallenges(domains []string) ([]authorizationResource, map[s
return challenges, failures return challenges, failures
} }
// requestCertificates iterates all granted authorizations, creates RSA private keys and CSRs. func (c *Client) requestCertificate(authz []authorizationResource, bundle bool, privKey crypto.PrivateKey) (CertificateResource, error) {
// 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) {
if len(authz) == 0 { if len(authz) == 0 {
return CertificateResource{}, errors.New("Passed no authorizations to requestCertificate!") return CertificateResource{}, errors.New("Passed no authorizations to requestCertificate!")
} }
commonName := authz[0] commonName := authz[0]
privKey, err := generatePrivateKey(rsakey, c.keyBits) var err error
if err != nil { if privKey == nil {
return CertificateResource{}, err privKey, err = generatePrivateKey(rsakey, c.keyBits)
if err != nil {
return CertificateResource{}, err
}
} }
var san []string var san []string
@ -584,7 +472,6 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool)
switch resp.StatusCode { switch resp.StatusCode {
case 202: case 202:
case 201: case 201:
cert, err := ioutil.ReadAll(limitReader(resp.Body, 1024*1024)) cert, err := ioutil.ReadAll(limitReader(resp.Body, 1024*1024))
resp.Body.Close() resp.Body.Close()
if err != nil { if err != nil {
@ -637,7 +524,7 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool)
return CertificateResource{}, handleHTTPError(resp) return CertificateResource{}, handleHTTPError(resp)
} }
resp, err = http.Get(cerRes.CertURL) resp, err = httpGet(cerRes.CertURL)
if err != nil { if err != nil {
return CertificateResource{}, err return CertificateResource{}, err
} }
@ -652,7 +539,7 @@ func (c *Client) getIssuerCertificate(url string) ([]byte, error) {
return c.issuerCert, nil return c.issuerCert, nil
} }
resp, err := http.Get(url) resp, err := httpGet(url)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -690,3 +577,43 @@ func parseLinks(links []string) map[string]string {
return linkMap 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
}
}
}

View file

@ -4,8 +4,10 @@ import (
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"encoding/json" "encoding/json"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
) )
@ -26,8 +28,7 @@ func TestNewClient(t *testing.T) {
w.Write(data) w.Write(data)
})) }))
caURL, optPort := ts.URL, "1234" client, err := NewClient(ts.URL, user, keyBits)
client, err := NewClient(caURL, user, keyBits, optPort)
if err != nil { if err != nil {
t.Fatalf("Could not create client: %v", err) 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 { if expected, actual := 2, len(client.solvers); actual != expected {
t.Fatalf("Expected %d solver(s), got %d", expected, actual) 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) httpSolver, ok := client.solvers["http-01"].(*httpChallenge)
if !ok { if !ok {
@ -54,9 +82,107 @@ func TestNewClient(t *testing.T) {
if httpSolver.jws != client.jws { if httpSolver.jws != client.jws {
t.Error("Expected http-01 to have same jws as client") t.Error("Expected http-01 to have same jws as client")
} }
if httpSolver.optPort != optPort { if httpSolver.port != optPort {
t.Errorf("Expected http-01 to have optPort %s but was %s", optPort, httpSolver.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 { type mockUser struct {

View file

@ -45,37 +45,38 @@ const (
) )
// GetOCSPForCert takes a PEM encoded cert or cert bundle returning the raw OCSP response, // 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. // the parsed response, and an error, if any. The returned []byte can be passed directly
// This []byte can be passed directly into the OCSPStaple property of a tls.Certificate. // into the OCSPStaple property of a tls.Certificate. If the bundle only contains the
// If the bundle only contains the issued certificate, this function will try // issued certificate, this function will try to get the issuer certificate from the
// to get the issuer certificate from the IssuingCertificateURL in the certificate. // IssuingCertificateURL in the certificate. If the []byte and/or ocsp.Response return
func GetOCSPForCert(bundle []byte) ([]byte, int, error) { // values are nil, the OCSP status may be assumed OCSPUnknown.
func GetOCSPForCert(bundle []byte) ([]byte, *ocsp.Response, error) {
certificates, err := parsePEMBundle(bundle) certificates, err := parsePEMBundle(bundle)
if err != nil { if err != nil {
return nil, OCSPUnknown, err return nil, nil, err
} }
// We only got one certificate, means we have no issuer certificate - get it. // We only got one certificate, means we have no issuer certificate - get it.
if len(certificates) == 1 { if len(certificates) == 1 {
// TODO: build fallback. If this fails, check the remaining array entries. // TODO: build fallback. If this fails, check the remaining array entries.
if len(certificates[0].IssuingCertificateURL) == 0 { 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 { if err != nil {
return nil, OCSPUnknown, err return nil, nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
issuerBytes, err := ioutil.ReadAll(limitReader(resp.Body, 1024*1024)) issuerBytes, err := ioutil.ReadAll(limitReader(resp.Body, 1024*1024))
if err != nil { if err != nil {
return nil, OCSPUnknown, err return nil, nil, err
} }
issuerCert, err := x509.ParseCertificate(issuerBytes) issuerCert, err := x509.ParseCertificate(issuerBytes)
if err != nil { if err != nil {
return nil, OCSPUnknown, err return nil, nil, err
} }
// Insert it into the slice on position 0 // 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. // Finally kick off the OCSP request.
ocspReq, err := ocsp.CreateRequest(issuedCert, issuerCert, nil) ocspReq, err := ocsp.CreateRequest(issuedCert, issuerCert, nil)
if err != nil { if err != nil {
return nil, OCSPUnknown, err return nil, nil, err
} }
reader := bytes.NewReader(ocspReq) 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 { if err != nil {
return nil, OCSPUnknown, err return nil, nil, err
} }
defer req.Body.Close() defer req.Body.Close()
ocspResBytes, err := ioutil.ReadAll(limitReader(req.Body, 1024*1024)) ocspResBytes, err := ioutil.ReadAll(limitReader(req.Body, 1024*1024))
ocspRes, err := ocsp.ParseResponse(ocspResBytes, issuerCert) ocspRes, err := ocsp.ParseResponse(ocspResBytes, issuerCert)
if err != nil { if err != nil {
return nil, OCSPUnknown, err return nil, nil, err
} }
if ocspRes.Certificate == nil { if ocspRes.Certificate == nil {
err = ocspRes.CheckSignatureFrom(issuerCert) err = ocspRes.CheckSignatureFrom(issuerCert)
if err != nil { 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) { func getKeyAuthorization(token string, key interface{}) (string, error) {
@ -201,6 +202,19 @@ func parsePEMBundle(bundle []byte) ([]*x509.Certificate, error) {
return certificates, nil 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) { func generatePrivateKey(t keyType, keyLength int) (crypto.PrivateKey, error) {
switch t { switch t {
case eckey: case eckey:

115
acme/http.go Normal file
View file

@ -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)
}

View file

@ -1,133 +1,63 @@
package acme package acme
import ( import (
"encoding/json"
"errors"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"strings" "strings"
"time"
) )
type httpChallenge struct { type httpChallenge struct {
jws *jws jws *jws
optPort string validate validateFunc
start chan net.Listener iface string
end chan error port string
} }
func (s *httpChallenge) Solve(chlng challenge, domain string) error { func (s *httpChallenge) Solve(chlng challenge, domain string) error {
logf("[INFO][%s] acme: Trying to solve HTTP-01", domain) 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 // Generate the Key Authorization for the challenge
keyAuth, err := getKeyAuthorization(chlng.Token, &s.jws.privKey.PublicKey) keyAuth, err := getKeyAuthorization(chlng.Token, &s.jws.privKey.PublicKey)
if err != nil { if err != nil {
return err return err
} }
go s.startHTTPServer(domain, chlng.Token, keyAuth) // Allow for CLI port override
var listener net.Listener port := "80"
select { if s.port != "" {
case listener = <-s.start: port = s.port
break }
case err := <-s.end:
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) 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 path := "/.well-known/acme-challenge/" + chlng.Token
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
// The handler validates the HOST header and request type. // The handler validates the HOST header and request type.
// For validation it then writes the token the server returned with the challenge // For validation it then writes the token the server returned with the challenge
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" { if strings.HasPrefix(r.Host, domain) && r.Method == "GET" {
w.Header().Add("Content-Type", "text/plain") w.Header().Add("Content-Type", "text/plain")
w.Write([]byte(keyAuth)) w.Write([]byte(keyAuth))
logf("[INFO] Served key authentication") logf("[INFO][%s] Served key authentication", domain)
} else { } else {
logf("[INFO] Received request for domain %s with method %s", r.Host, r.Method) logf("[INFO] Received request for domain %s with method %s", r.Host, r.Method)
w.Write([]byte("TEST")) w.Write([]byte("TEST"))
} }
}) })
http.Serve(listener, nil) go http.Serve(listener, mux)
// Signal that the server was shut down return s.validate(s.jws, domain, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth})
s.end <- nil
} }

View file

@ -2,263 +2,55 @@ package acme
import ( import (
"crypto/rsa" "crypto/rsa"
"crypto/tls"
"encoding/json"
"io/ioutil" "io/ioutil"
"net/http"
"net/http/httptest"
"regexp"
"strings" "strings"
"testing" "testing"
"github.com/square/go-jose"
) )
func TestHTTPNonRootBind(t *testing.T) { func TestHTTPChallenge(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) {
privKey, _ := generatePrivateKey(rsakey, 512) privKey, _ := generatePrivateKey(rsakey, 512)
jws := &jws{privKey: privKey.(*rsa.PrivateKey), nonces: []string{"test1", "test2"}} j := &jws{privKey: privKey.(*rsa.PrivateKey)}
clientChallenge := challenge{Type: "http-01", Token: "http1"}
solver := &httpChallenge{jws: jws, optPort: "23456"} mockValidate := func(_ *jws, _, _ string, chlng challenge) error {
clientChallenge := challenge{Type: "http01", Status: "pending", URI: "http://localhost:4000", Token: "http3"} uri := "http://localhost:23457/.well-known/acme-challenge/" + chlng.Token
resp, err := httpGet(uri)
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))
if err != nil { if err != nil {
t.Errorf("Client sent invalid JWS to the server.\n\t%v", err) return 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)
} }
defer resp.Body.Close() 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) bodyStr := string(body)
if resp.Header.Get("Content-Type") != "text/plain" { if bodyStr != chlng.KeyAuthorization {
t.Errorf("Expected server to respond with content type text/plain.") t.Errorf("Get(%q) Body: got %q, want %q", uri, bodyStr, chlng.KeyAuthorization)
} }
tokenRegex := regexp.MustCompile("^[\\w-]{43}$") return nil
parts := strings.Split(bodyStr, ".") }
solver := &httpChallenge{jws: j, validate: mockValidate, port: "23457"}
if len(parts) != 2 { if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil {
t.Errorf("Expected server token to be a composite of two strings, seperated by a dot") t.Errorf("Solve error: got %v, want nil", err)
} }
}
if parts[0] != clientChallenge.Token {
t.Errorf("Expected the first part of the server token to be the challenge token.") func TestHTTPChallengeInvalidPort(t *testing.T) {
} privKey, _ := generatePrivateKey(rsakey, 128)
j := &jws{privKey: privKey.(*rsa.PrivateKey)}
if !tokenRegex.MatchString(parts[1]) { clientChallenge := challenge{Type: "http-01", Token: "http2"}
t.Errorf("Expected the second part of the server token to be a properly formatted key authorization") solver := &httpChallenge{jws: j, validate: stubValidate, port: "123456"}
}
if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil {
valid := challenge{Type: "http01", Status: "valid", URI: ts.URL, Token: "1234567812"} t.Errorf("Solve error: got %v, want error", err)
jsonBytes, _ := json.Marshal(&valid) } else if want := "invalid port 123456"; !strings.HasSuffix(err.Error(), want) {
w.Write(jsonBytes) t.Errorf("Solve error: got %q, want suffix %q", err.Error(), want)
})
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)
} }
} }

88
acme/http_test.go Normal file
View file

@ -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)
}
}

View file

@ -35,7 +35,7 @@ func (j *jws) post(url string, content []byte) (*http.Response, error) {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@ -71,7 +71,7 @@ func (j *jws) getNonceFromResponse(resp *http.Response) error {
} }
func (j *jws) getNonce() error { func (j *jws) getNonce() error {
resp, err := http.Head(j.directoryURL) resp, err := httpHead(j.directoryURL)
if err != nil { if err != nil {
return err return err
} }

View file

@ -5,19 +5,16 @@ import (
"crypto/sha256" "crypto/sha256"
"crypto/tls" "crypto/tls"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"time"
) )
type tlsSNIChallenge struct { type tlsSNIChallenge struct {
jws *jws jws *jws
optPort string validate validateFunc
start chan net.Listener iface string
end chan error port string
} }
func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { 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) 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 // Generate the Key Authorization for the challenge
keyAuth, err := getKeyAuthorization(chlng.Token, &t.jws.privKey.PublicKey) keyAuth, err := getKeyAuthorization(chlng.Token, &t.jws.privKey.PublicKey)
if err != nil { if err != nil {
return err return err
} }
certificate, err := t.generateCertificate(keyAuth) cert, err := t.generateCertificate(keyAuth)
if err != nil { if err != nil {
return err return err
} }
go t.startSNITLSServer(certificate) // Allow for CLI port override
var listener net.Listener port := "443"
select { if t.port != "" {
case listener = <-t.start: port = t.port
break }
case err := <-t.end:
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) 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 go http.Serve(listener, nil)
defer func() {
listener.Close()
err = <-t.end
close(t.start)
close(t.end)
}()
jsonBytes, err := json.Marshal(challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) return t.validate(t.jws, domain, chlng.URI, 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
} }
func (t *tlsSNIChallenge) generateCertificate(keyAuth string) (tls.Certificate, error) { func (t *tlsSNIChallenge) generateCertificate(keyAuth string) (tls.Certificate, error) {
@ -128,26 +85,3 @@ func (t *tlsSNIChallenge) generateCertificate(keyAuth string) (tls.Certificate,
return certificate, nil 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
}

View file

@ -5,61 +5,17 @@ import (
"crypto/sha256" "crypto/sha256"
"crypto/tls" "crypto/tls"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"io/ioutil" "strings"
"net/http"
"net/http/httptest"
"testing" "testing"
"github.com/square/go-jose"
) )
func TestTLSSNINonRootBind(t *testing.T) { func TestTLSSNIChallenge(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) {
privKey, _ := generatePrivateKey(rsakey, 512) privKey, _ := generatePrivateKey(rsakey, 512)
optPort := "5001" j := &jws{privKey: privKey.(*rsa.PrivateKey)}
clientChallenge := challenge{Type: "tls-sni-01", Token: "tlssni1"}
ts := httptest.NewServer(nil) mockValidate := func(_ *jws, _, _ string, chlng challenge) error {
conn, err := tls.Dial("tcp", "localhost:23457", &tls.Config{
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{
InsecureSkipVerify: true, InsecureSkipVerify: true,
}) })
if err != nil { 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) 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]) z := hex.EncodeToString(zBytes[:sha256.Size])
domain := fmt.Sprintf("%s.%s.acme.invalid", z[:32], z[32:]) 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]) 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"} return nil
jsonBytes, _ := json.Marshal(&valid) }
w.Write(jsonBytes) solver := &tlsSNIChallenge{jws: j, validate: mockValidate, port: "23457"}
})
jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil {
solver := &tlsSNIChallenge{jws: jws, optPort: optPort} t.Errorf("Solve error: got %v, want nil", err)
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()) 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)
} }
} }

31
cli.go
View file

@ -4,8 +4,10 @@ import (
"log" "log"
"os" "os"
"path" "path"
"strings"
"github.com/codegangsta/cli" "github.com/codegangsta/cli"
"github.com/xenolf/lego/acme"
) )
// Logger is used to log errors; if nil, the default log.Logger is used. // Logger is used to log errors; if nil, the default log.Logger is used.
@ -19,12 +21,21 @@ func logger() *log.Logger {
return Logger return Logger
} }
func main() { var gittag string
func main() {
app := cli.NewApp() app := cli.NewApp()
app.Name = "lego" app.Name = "lego"
app.Usage = "Let's encrypt client to go!" 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() cwd, err := os.Getwd()
if err != nil { if err != nil {
@ -53,6 +64,10 @@ func main() {
Value: 0, Value: 0,
Usage: "The number of days left on a certificate to renew it.", 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", Usage: "Directory to use for storing the data",
Value: defaultPath, Value: defaultPath,
}, },
cli.StringSliceFlag{
Name: "exclude, x",
Usage: "Explicitly disallow solvers by name from being used. Solvers: \"http-01\", \"tls-sni-01\".",
},
cli.StringFlag{ cli.StringFlag{
Name: "port", Name: "http",
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", 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",
}, },
} }

View file

@ -34,11 +34,23 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
//TODO: move to account struct? Currently MUST pass email. //TODO: move to account struct? Currently MUST pass email.
acc := NewAccount(c.GlobalString("email"), conf) 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 { if err != nil {
logger().Fatalf("Could not create client: %s", err.Error()) 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 return conf, acc, client
} }
@ -126,7 +138,7 @@ func run(c *cli.Context) {
logger().Fatal("Please specify --domains or -d") 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 { if len(failures) > 0 {
for k, v := range failures { for k, v := range failures {
logger().Printf("[%s] Could not obtain certificates\n\t%s", k, v.Error()) 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) metaBytes, err := ioutil.ReadFile(metaPath)
if err != nil { if err != nil {
logger().Fatalf("Error while loading the meta data for domain %s\n\t%s", domain, err.Error()) 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()) 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 certRes.Certificate = certBytes
newCert, err := client.RenewCertificate(certRes, true, true) newCert, err := client.RenewCertificate(certRes, true)
if err != nil { if err != nil {
logger().Fatalf("%s", err.Error()) logger().Fatalf("%s", err.Error())
} }

View file

@ -24,8 +24,8 @@ func (c *Configuration) RsaBits() int {
return c.context.GlobalInt("rsa-key-size") return c.context.GlobalInt("rsa-key-size")
} }
func (c *Configuration) OptPort() string { func (c *Configuration) ExcludedSolvers() []string {
return c.context.GlobalString("port") return c.context.GlobalStringSlice("exclude")
} }
// ServerPath returns the OS dependent path to the data for a specific CA // ServerPath returns the OS dependent path to the data for a specific CA