diff --git a/acme/internal/acme/acme.go b/acme/internal/acme/acme.go index 2732999..a16f0bb 100644 --- a/acme/internal/acme/acme.go +++ b/acme/internal/acme/acme.go @@ -19,32 +19,68 @@ import ( "net/http" "strconv" "strings" + "sync" "time" "golang.org/x/net/context" ) +// LetsEncryptURL is the Directory endpoint of Let's Encrypt CA. +const LetsEncryptURL = "https://acme-v01.api.letsencrypt.org/directory" + // Client is an ACME client. +// The only required field is Key. An example of creating a client with a new key +// is as follows: +// +// key, err := rsa.GenerateKey(rand.Reader, 2048) +// if err != nil { +// log.Fatal(err) +// } +// client := &Client{Key: key} +// type Client struct { // HTTPClient optionally specifies an HTTP client to use // instead of http.DefaultClient. HTTPClient *http.Client - // Key is the account key used to register with a CA - // and sign requests. + // Key is the account key used to register with a CA and sign requests. Key *rsa.PrivateKey + + // DirectoryURL points to the CA directory endpoint. + // If empty, LetsEncryptURL is used. + // Mutating this value after a successful call of Client's Discover method + // will have no effect. + DirectoryURL string + + dirMu sync.Mutex // guards writes to dir + dir *Directory // cached result of Client's Discover method } -// Discover performs ACME server discovery using the provided discovery endpoint URL. -func (c *Client) Discover(url string) (*Directory, error) { - res, err := c.httpClient().Get(url) +// Discover performs ACME server discovery using c.DirectoryURL. +// +// It caches successful result. So, subsequent calls will not result in +// a network round-trip. This also means mutating c.DirectoryURL after successful call +// of this method will have no effect. +func (c *Client) Discover() (Directory, error) { + c.dirMu.Lock() + defer c.dirMu.Unlock() + if c.dir != nil { + return *c.dir, nil + } + + dirURL := c.DirectoryURL + if dirURL == "" { + dirURL = LetsEncryptURL + } + res, err := c.httpClient().Get(dirURL) if err != nil { - return nil, err + return Directory{}, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return nil, responseError(res) + return Directory{}, responseError(res) } + var v struct { Reg string `json:"new-reg"` Authz string `json:"new-authz"` @@ -57,9 +93,9 @@ func (c *Client) Discover(url string) (*Directory, error) { } } if json.NewDecoder(res.Body).Decode(&v); err != nil { - return nil, err + return Directory{}, err } - return &Directory{ + c.dir = &Directory{ RegURL: v.Reg, AuthzURL: v.Authz, CertURL: v.Cert, @@ -67,7 +103,8 @@ func (c *Client) Discover(url string) (*Directory, error) { Terms: v.Meta.Terms, Website: v.Meta.Website, CAA: v.Meta.CAA, - }, nil + } + return *c.dir, nil } // CreateCert requests a new certificate. @@ -76,9 +113,12 @@ func (c *Client) Discover(url string) (*Directory, error) { // In such scenario the caller can cancel the polling with ctx. // // If the bundle is true, the returned value will also contain CA (the issuer) certificate. -// The url argument is an Directory.CertURL value, typically obtained from c.Discover. // The csr is a DER encoded certificate signing request. -func (c *Client) CreateCert(ctx context.Context, url string, csr []byte, exp time.Duration, bundle bool) (der [][]byte, certURL string, err error) { +func (c *Client) CreateCert(ctx context.Context, csr []byte, exp time.Duration, bundle bool) (der [][]byte, certURL string, err error) { + if _, err := c.Discover(); err != nil { + return nil, "", err + } + req := struct { Resource string `json:"resource"` CSR string `json:"csr"` @@ -94,7 +134,7 @@ func (c *Client) CreateCert(ctx context.Context, url string, csr []byte, exp tim req.NotAfter = now.Add(exp).Format(time.RFC3339) } - res, err := c.postJWS(url, req) + res, err := c.postJWS(c.dir.CertURL, req) if err != nil { return nil, "", err } @@ -118,9 +158,7 @@ func (c *Client) CreateCert(ctx context.Context, url string, csr []byte, exp tim // It retries the request until the certificate is successfully retrieved, // context is cancelled by the caller or an error response is received. // -// The returned value will also contain CA (the issuer) certificate if bundle == true. -// -// http.DefaultClient is used if client argument is nil. +// The returned value will also contain CA (the issuer) certificate if bundle is true. func (c *Client) FetchCert(ctx context.Context, url string, bundle bool) ([][]byte, error) { for { res, err := c.httpClient().Get(url) @@ -149,14 +187,15 @@ func (c *Client) FetchCert(ctx context.Context, url string, bundle bool) ([][]by // Register creates a new account registration by following the "new-reg" flow. // It returns registered account. The a argument is not modified. -// -// The url argument is typically an Directory.RegURL obtained from c.Discover. -func (c *Client) Register(url string, a *Account) (*Account, error) { - return c.doReg(url, "new-reg", a) +func (c *Client) Register(a *Account) (*Account, error) { + if _, err := c.Discover(); err != nil { + return nil, err + } + return c.doReg(c.dir.RegURL, "new-reg", a) } // GetReg retrieves an existing registration. -// The url argument is an Account.URI, typically obtained from c.Register. +// The url argument is an Account URI. func (c *Client) GetReg(url string) (*Account, error) { a := &Account{URI: url} return c.doReg(url, "reg", a) @@ -164,18 +203,18 @@ func (c *Client) GetReg(url string) (*Account, error) { // UpdateReg updates an existing registration. // It returns an updated account copy. The provided account is not modified. -// -// The url argument is an Account.URI, usually obtained with c.Register. -func (c *Client) UpdateReg(url string, a *Account) (*Account, error) { - return c.doReg(url, "reg", a) +func (c *Client) UpdateReg(a *Account) (*Account, error) { + return c.doReg(a.URI, "reg", a) } // Authorize performs the initial step in an authorization flow. // The caller will then need to choose from and perform a set of returned // challenges using c.Accept in order to successfully complete authorization. -// -// The url argument is an authz URL, usually obtained with c.Register. -func (c *Client) Authorize(url, domain string) (*Authorization, error) { +func (c *Client) Authorize(domain string) (*Authorization, error) { + if _, err := c.Discover(); err != nil { + return nil, err + } + type authzID struct { Type string `json:"type"` Value string `json:"value"` @@ -187,7 +226,7 @@ func (c *Client) Authorize(url, domain string) (*Authorization, error) { Resource: "new-authz", Identifier: authzID{Type: "dns", Value: domain}, } - res, err := c.postJWS(url, req) + res, err := c.postJWS(c.dir.AuthzURL, req) if err != nil { return nil, err } diff --git a/acme/internal/acme/acme_test.go b/acme/internal/acme/acme_test.go index f9d17c3..6d7c424 100644 --- a/acme/internal/acme/acme_test.go +++ b/acme/internal/acme/acme_test.go @@ -58,21 +58,22 @@ func TestDiscover(t *testing.T) { }`, reg, authz, cert, revoke) })) defer ts.Close() - ep, err := (&Client{}).Discover(ts.URL) + c := Client{DirectoryURL: ts.URL} + dir, err := c.Discover() if err != nil { t.Fatal(err) } - if ep.RegURL != reg { - t.Errorf("RegURL = %q; want %q", ep.RegURL, reg) + if dir.RegURL != reg { + t.Errorf("dir.RegURL = %q; want %q", dir.RegURL, reg) } - if ep.AuthzURL != authz { - t.Errorf("authzURL = %q; want %q", ep.AuthzURL, authz) + if dir.AuthzURL != authz { + t.Errorf("dir.AuthzURL = %q; want %q", dir.AuthzURL, authz) } - if ep.CertURL != cert { - t.Errorf("certURL = %q; want %q", ep.CertURL, cert) + if dir.CertURL != cert { + t.Errorf("dir.CertURL = %q; want %q", dir.CertURL, cert) } - if ep.RevokeURL != revoke { - t.Errorf("revokeURL = %q; want %q", ep.RevokeURL, revoke) + if dir.RevokeURL != revoke { + t.Errorf("dir.RevokeURL = %q; want %q", dir.RevokeURL, revoke) } } @@ -116,10 +117,10 @@ func TestRegister(t *testing.T) { })) defer ts.Close() - c := Client{Key: testKey} + c := Client{Key: testKey, dir: &Directory{RegURL: ts.URL}} a := &Account{Contact: contacts} var err error - if a, err = c.Register(ts.URL, a); err != nil { + if a, err = c.Register(a); err != nil { t.Fatal(err) } if a.URI != "https://ca.tld/acme/reg/1" { @@ -181,9 +182,9 @@ func TestUpdateReg(t *testing.T) { defer ts.Close() c := Client{Key: testKey} - a := &Account{Contact: contacts, AgreedTerms: terms} + a := &Account{URI: ts.URL, Contact: contacts, AgreedTerms: terms} var err error - if a, err = c.UpdateReg(ts.URL, a); err != nil { + if a, err = c.UpdateReg(a); err != nil { t.Fatal(err) } if a.Authz != "https://ca.tld/acme/new-authz" { @@ -311,8 +312,8 @@ func TestAuthorize(t *testing.T) { })) defer ts.Close() - cl := Client{Key: testKey} - auth, err := cl.Authorize(ts.URL, "example.com") + cl := Client{Key: testKey, dir: &Directory{AuthzURL: ts.URL}} + auth, err := cl.Authorize("example.com") if err != nil { t.Fatal(err) } @@ -605,8 +606,8 @@ func TestNewCert(t *testing.T) { t.Fatal(err) } - c := Client{Key: testKey} - cert, certURL, err := c.CreateCert(context.Background(), ts.URL, csrb, notAfter.Sub(notBefore), false) + c := Client{Key: testKey, dir: &Directory{CertURL: ts.URL}} + cert, certURL, err := c.CreateCert(context.Background(), csrb, notAfter.Sub(notBefore), false) if err != nil { t.Fatal(err) }