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]
### 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

View file

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

View file

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

View file

@ -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 {

View file

@ -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:

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

View file

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

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

View file

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

View file

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

31
cli.go
View file

@ -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",
},
}

View file

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

View file

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