feat: add zerossl support

This commit is contained in:
Fernandez Ludovic 2026-01-25 03:51:29 +01:00
commit b85045c891
8 changed files with 311 additions and 0 deletions

View file

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

View file

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

View 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/")
}

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

View file

@ -0,0 +1,8 @@
{
"success": false,
"error": {
"code": 101,
"type": "invalid_access_key",
"info": "You have not supplied a valid API Access Key."
}
}

View file

@ -0,0 +1,8 @@
{
"success": false,
"error": {
"code": 2900,
"type": "missing_email",
"info": null
}
}

View file

@ -0,0 +1,5 @@
{
"success": true,
"eab_kid": "GD-VvWydSVFuss_GhBwYQQ",
"eab_hmac_key": "MjXU3MH-Z0WQ7piMAnVsCpD1shgMiWx6ggPWiTmydgUaj7dWWWfQfA"
}

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