Merge branch 'refactor-client'
This commit is contained in:
commit
1369fa9f3c
15 changed files with 707 additions and 749 deletions
23
CHANGELOG.md
23
CHANGELOG.md
|
@ -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
|
||||||
|
|
||||||
|
|
36
README.md
36
README.md
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
345
acme/client.go
345
acme/client.go
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
115
acme/http.go
Normal 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)
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
88
acme/http_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
31
cli.go
|
@ -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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue