mirror of
https://github.com/go-acme/lego
synced 2026-03-14 14:35:48 +01:00
feat: add zerossl support
This commit is contained in:
parent
b30488ad1c
commit
b85045c891
8 changed files with 311 additions and 0 deletions
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/go-acme/lego/v5/lego"
|
||||
"github.com/go-acme/lego/v5/log"
|
||||
"github.com/go-acme/lego/v5/registration"
|
||||
"github.com/go-acme/lego/v5/registration/zerossl"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
|
|
@ -94,6 +95,8 @@ func registerAccount(ctx context.Context, cmd *cli.Command, client *lego.Client)
|
|||
Kid: kid,
|
||||
HmacEncoded: hmacEncoded,
|
||||
})
|
||||
} else if zerossl.IsZeroSSL(getCA(cmd)) {
|
||||
return registration.RegisterWithZeroSSL(ctx, client.Registration, cmd.String(flgEmail))
|
||||
}
|
||||
|
||||
return client.Registration.Register(ctx, registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||
|
|
|
|||
|
|
@ -3,12 +3,15 @@ package registration
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/go-acme/lego/v5/acme"
|
||||
"github.com/go-acme/lego/v5/acme/api"
|
||||
"github.com/go-acme/lego/v5/log"
|
||||
"github.com/go-acme/lego/v5/registration/zerossl"
|
||||
)
|
||||
|
||||
const mailTo = "mailto:"
|
||||
|
|
@ -170,3 +173,34 @@ func (r *Registrar) ResolveAccountByKey(ctx context.Context) (*Resource, error)
|
|||
|
||||
return &Resource{URI: account.Location, Body: account.Account}, nil
|
||||
}
|
||||
|
||||
// RegisterWithZeroSSL registers the current account to the ZeroSSL.
|
||||
// It uses either an access key or an email to generate an EAB.
|
||||
func RegisterWithZeroSSL(ctx context.Context, r *Registrar, email string) (*Resource, error) {
|
||||
zc := zerossl.NewClient()
|
||||
|
||||
value, find := os.LookupEnv(zerossl.EnvZeroSSLAccessKey)
|
||||
if find {
|
||||
eab, err := zc.GenerateEAB(ctx, value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zerossl: generate EAB: %w", err)
|
||||
}
|
||||
|
||||
return r.RegisterWithExternalAccountBinding(ctx, RegisterEABOptions{
|
||||
TermsOfServiceAgreed: true,
|
||||
Kid: eab.Kid,
|
||||
HmacEncoded: eab.HmacKey,
|
||||
})
|
||||
}
|
||||
|
||||
eab, err := zc.GenerateEABFromEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zerossl: generate EAB from email: %w", err)
|
||||
}
|
||||
|
||||
return r.RegisterWithExternalAccountBinding(ctx, RegisterEABOptions{
|
||||
TermsOfServiceAgreed: true,
|
||||
Kid: eab.Kid,
|
||||
HmacEncoded: eab.HmacKey,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
125
registration/zerossl/client.go
Normal file
125
registration/zerossl/client.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package zerossl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v5/internal/errutils"
|
||||
"github.com/go-acme/lego/v5/internal/useragent"
|
||||
)
|
||||
|
||||
const EnvZeroSSLAccessKey = "ZERO_SSL_ACCESS_KEY"
|
||||
|
||||
const defaultBaseURL = "https://api.zerossl.com"
|
||||
|
||||
// Client is a ZeroSSL API client.
|
||||
type Client struct {
|
||||
baseURL *url.URL
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient returns a new ZeroSSL API client.
|
||||
func NewClient() *Client {
|
||||
baseURL, _ := url.Parse(defaultBaseURL)
|
||||
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateEAB generates a new EAB credential.
|
||||
func (c *Client) GenerateEAB(ctx context.Context, accessKey string) (*APIResponse, error) {
|
||||
endpoint := c.baseURL.JoinPath("acme", "eab-credentials")
|
||||
|
||||
query := endpoint.Query()
|
||||
query.Set("access_key", accessKey)
|
||||
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to create request: %w", err)
|
||||
}
|
||||
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
func (c *Client) GenerateEABFromEmail(ctx context.Context, email string) (*APIResponse, error) {
|
||||
if email == "" {
|
||||
return nil, errors.New("no email provided")
|
||||
}
|
||||
|
||||
endpoint := c.baseURL.JoinPath("acme", "eab-credentials-email")
|
||||
|
||||
payload := url.Values{}
|
||||
payload.Set("email", email)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(payload.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
func (c *Client) do(req *http.Request) (*APIResponse, error) {
|
||||
useragent.SetHeader(req.Header)
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, errutils.NewHTTPDoError(req, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return nil, parseError(req, resp)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
|
||||
}
|
||||
|
||||
result := new(APIResponse)
|
||||
|
||||
err = json.Unmarshal(raw, result)
|
||||
if err != nil {
|
||||
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseError(req *http.Request, resp *http.Response) error {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
|
||||
errAPI := new(APIResponse)
|
||||
|
||||
err := json.Unmarshal(raw, errAPI)
|
||||
if err != nil {
|
||||
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
|
||||
}
|
||||
|
||||
return errAPI.Error
|
||||
}
|
||||
|
||||
func IsZeroSSL(server string) bool {
|
||||
return strings.HasPrefix(server, "https://acme.zerossl.com/")
|
||||
}
|
||||
95
registration/zerossl/client_test.go
Normal file
95
registration/zerossl/client_test.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package zerossl
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/go-acme/lego/v5/platform/tester/servermock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func mockBuilder() *servermock.Builder[*Client] {
|
||||
return servermock.NewBuilder[*Client](
|
||||
func(server *httptest.Server) (*Client, error) {
|
||||
client := NewClient()
|
||||
|
||||
client.baseURL, _ = url.Parse(server.URL)
|
||||
client.httpClient = server.Client()
|
||||
|
||||
return client, nil
|
||||
},
|
||||
servermock.CheckHeader().
|
||||
With("Accept", "application/json"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestClient_GenerateEAB(t *testing.T) {
|
||||
client := mockBuilder().
|
||||
Route("POST /acme/eab-credentials",
|
||||
servermock.ResponseFromFixture("success.json"),
|
||||
servermock.CheckQueryParameter().Strict().
|
||||
With("access_key", "secret"),
|
||||
).
|
||||
Build(t)
|
||||
|
||||
eab, err := client.GenerateEAB(t.Context(), "secret")
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := &APIResponse{
|
||||
Success: true,
|
||||
Kid: "GD-VvWydSVFuss_GhBwYQQ",
|
||||
HmacKey: "MjXU3MH-Z0WQ7piMAnVsCpD1shgMiWx6ggPWiTmydgUaj7dWWWfQfA",
|
||||
}
|
||||
|
||||
assert.Equal(t, expected, eab)
|
||||
}
|
||||
|
||||
func TestClient_GenerateEAB_error(t *testing.T) {
|
||||
client := mockBuilder().
|
||||
Route("POST /acme/eab-credentials",
|
||||
servermock.ResponseFromFixture("error.json").
|
||||
WithStatusCode(http.StatusUnauthorized),
|
||||
).
|
||||
Build(t)
|
||||
|
||||
_, err := client.GenerateEAB(t.Context(), "foo")
|
||||
require.EqualError(t, err, "101: invalid_access_key: You have not supplied a valid API Access Key.")
|
||||
}
|
||||
|
||||
func TestClient_GenerateEABFromEmail(t *testing.T) {
|
||||
client := mockBuilder().
|
||||
Route("POST /acme/eab-credentials-email",
|
||||
servermock.ResponseFromFixture("success.json"),
|
||||
servermock.CheckHeader().
|
||||
WithContentTypeFromURLEncoded(),
|
||||
servermock.CheckForm().
|
||||
With("email", "test@exmample.com"),
|
||||
).
|
||||
Build(t)
|
||||
|
||||
eab, err := client.GenerateEABFromEmail(t.Context(), "test@exmample.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := &APIResponse{
|
||||
Success: true,
|
||||
Kid: "GD-VvWydSVFuss_GhBwYQQ",
|
||||
HmacKey: "MjXU3MH-Z0WQ7piMAnVsCpD1shgMiWx6ggPWiTmydgUaj7dWWWfQfA",
|
||||
}
|
||||
|
||||
assert.Equal(t, expected, eab)
|
||||
}
|
||||
|
||||
func TestClient_GenerateEABFromEmail_error(t *testing.T) {
|
||||
client := mockBuilder().
|
||||
Route("POST /acme/eab-credentials-email",
|
||||
// NOTE: with this endpoint the server always returns a 200.
|
||||
servermock.ResponseFromFixture("error_email.json"),
|
||||
).
|
||||
Build(t)
|
||||
|
||||
_, err := client.GenerateEABFromEmail(t.Context(), "test@exmample.com")
|
||||
require.EqualError(t, err, "2900: missing_email")
|
||||
}
|
||||
8
registration/zerossl/fixtures/error.json
Normal file
8
registration/zerossl/fixtures/error.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": 101,
|
||||
"type": "invalid_access_key",
|
||||
"info": "You have not supplied a valid API Access Key."
|
||||
}
|
||||
}
|
||||
8
registration/zerossl/fixtures/error_email.json
Normal file
8
registration/zerossl/fixtures/error_email.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": 2900,
|
||||
"type": "missing_email",
|
||||
"info": null
|
||||
}
|
||||
}
|
||||
5
registration/zerossl/fixtures/success.json
Normal file
5
registration/zerossl/fixtures/success.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"success": true,
|
||||
"eab_kid": "GD-VvWydSVFuss_GhBwYQQ",
|
||||
"eab_hmac_key": "MjXU3MH-Z0WQ7piMAnVsCpD1shgMiWx6ggPWiTmydgUaj7dWWWfQfA"
|
||||
}
|
||||
33
registration/zerossl/types.go
Normal file
33
registration/zerossl/types.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package zerossl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
|
||||
Kid string `json:"eab_kid"`
|
||||
HmacKey string `json:"eab_hmac_key"`
|
||||
|
||||
Error *ErrorDetail `json:"error"`
|
||||
}
|
||||
|
||||
type ErrorDetail struct {
|
||||
Code int `json:"code"`
|
||||
Type string `json:"type"`
|
||||
Info string `json:"info"`
|
||||
}
|
||||
|
||||
func (e *ErrorDetail) Error() string {
|
||||
var msg strings.Builder
|
||||
|
||||
msg.WriteString(fmt.Sprintf("%d: %s", e.Code, e.Type))
|
||||
|
||||
if e.Info != "" {
|
||||
msg.WriteString(fmt.Sprintf(": %s", e.Info))
|
||||
}
|
||||
|
||||
return msg.String()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue