pax_global_header00006660000000000000000000000064151532064300014511gustar00rootroot0000000000000052 comment=e92df569bee7930ac1b0febb89d69973e05dc550 golang-github-caddyserver-zerossl-0.1.5/000077500000000000000000000000001515320643000202535ustar00rootroot00000000000000golang-github-caddyserver-zerossl-0.1.5/.gitignore000066400000000000000000000000241515320643000222370ustar00rootroot00000000000000_gitignore .DS_Storegolang-github-caddyserver-zerossl-0.1.5/LICENSE000066400000000000000000000020541515320643000212610ustar00rootroot00000000000000MIT License Copyright (c) 2024 Matthew Holt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.golang-github-caddyserver-zerossl-0.1.5/README.md000066400000000000000000000006331515320643000215340ustar00rootroot00000000000000ZeroSSL API client [![Go Reference](https://pkg.go.dev/badge/github.com/caddyserver/zerossl.svg)](https://pkg.go.dev/github.com/caddyserver/zerossl) ================== This package implements the [ZeroSSL REST API](https://zerossl.com/documentation/api/) in Go. The REST API is distinct from the [ACME endpoint](https://zerossl.com/documentation/acme/), which is a standardized way of obtaining certificates. golang-github-caddyserver-zerossl-0.1.5/client.go000066400000000000000000000106441515320643000220650ustar00rootroot00000000000000package zerossl import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) // Client acts as a ZeroSSL API client. It facilitates ZeroSSL certificate operations. type Client struct { // REQUIRED: Your ZeroSSL account access key. AccessKey string `json:"access_key"` // Optionally adjust the base URL of the API. // Default: https://api.zerossl.com BaseURL string `json:"base_url,omitempty"` // Optionally configure a custom HTTP client. HTTPClient *http.Client `json:"-"` } func (c Client) httpGet(ctx context.Context, endpoint string, qs url.Values, target any) error { url := c.url(endpoint, qs) return c.httpRequest(ctx, http.MethodGet, url, nil, target) } func (c Client) httpPost(ctx context.Context, endpoint string, qs url.Values, payload, target any) error { var reqBody io.Reader if payload != nil { payloadJSON, err := json.Marshal(payload) if err != nil { return err } reqBody = bytes.NewReader(payloadJSON) } url := c.url(endpoint, qs) return c.httpRequest(ctx, http.MethodPost, url, reqBody, target) } func (c Client) httpRequest(ctx context.Context, method, reqURL string, reqBody io.Reader, target any) error { r, err := http.NewRequestWithContext(ctx, method, reqURL, reqBody) if err != nil { return err } if reqBody != nil { r.Header.Set("Content-Type", "application/json") } resp, err := c.httpClient().Do(r) if err != nil { return err } defer resp.Body.Close() //nolint:errcheck // because the ZeroSSL API doesn't use HTTP status codes to indicate an error, // nor does each response body have a consistent way of detecting success/error, // we have to work around this by buffering the entire response body and then // checking it for expected value(s) to determine if there's an error respBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024*5)) if err != nil { return fmt.Errorf("failed reading response body: %v", err) } // assume error first, since the ZeroSSL API does not use status codes for errors // (see https://github.com/caddyserver/zerossl/issues/3) var apiError APIError if err := json.NewDecoder(bytes.NewReader(respBytes)).Decode(&apiError); err != nil { return fmt.Errorf("decoding JSON error body failed: %v (raw=%s)", err, respBytes) } if apiError.Success != nil && !*apiError.Success { // remove access_key from URL so it doesn't leak into logs u, err := url.Parse(reqURL) if err != nil { reqURL = fmt.Sprintf("", err) } if u != nil { q, err := url.ParseQuery(u.RawQuery) if err == nil { q.Set(accessKeyParam, "redacted") u.RawQuery = q.Encode() reqURL = u.String() } } apiError.URL = reqURL return apiError } // if there was no error, decode into target payload if target != nil { if err = json.NewDecoder(bytes.NewReader(respBytes)).Decode(target); err != nil { return fmt.Errorf("request succeeded, but decoding JSON response body failed: %v (raw=%s)", err, respBytes) } } return nil } func (c Client) url(endpoint string, qs url.Values) string { baseURL := c.BaseURL if baseURL == "" { baseURL = BaseURL } // for consistency, ensure endpoint starts with / // and base URL does NOT end with /. if !strings.HasPrefix(endpoint, "/") { endpoint = "/" + endpoint } baseURL = strings.TrimSuffix(baseURL, "/") if qs == nil { qs = url.Values{} } qs.Set(accessKeyParam, c.AccessKey) return fmt.Sprintf("%s%s?%s", baseURL, endpoint, qs.Encode()) } func (c Client) httpClient() *http.Client { if c.HTTPClient != nil { return c.HTTPClient } return httpClient } var httpClient = &http.Client{ Timeout: 2 * time.Minute, } // anyBool is a hacky type that accepts true or 1 (or their string variants), // or "yes" or "y", and any casing variants of the same, as a boolean true when // unmarshaling JSON. Everything else is boolean false. // // This is needed due to type inconsistencies in ZeroSSL's API with "success" values. type anyBool bool // UnmarshalJSON satisfies json.Unmarshaler according to // this type's documentation. func (ab *anyBool) UnmarshalJSON(b []byte) error { if len(b) == 0 { return io.EOF } switch strings.ToLower(string(b)) { case `true`, `"true"`, `1`, `"1"`, `"yes"`, `"y"`: *ab = true } return nil } // MarshalJSON marshals ab to either true or false. func (ab *anyBool) MarshalJSON() ([]byte, error) { if ab != nil && *ab { return []byte("true"), nil } return []byte("false"), nil } const accessKeyParam = "access_key" golang-github-caddyserver-zerossl-0.1.5/endpoints.go000066400000000000000000000173551515320643000226200ustar00rootroot00000000000000package zerossl import ( "context" "crypto/x509" "fmt" "io" "net/http" "net/url" "strconv" "strings" ) // CreateCertificate creates a certificate. After creating a certificate, its identifiers must be verified before // the certificate can be downloaded. The CSR must have been fully created using x509.CreateCertificateRequest // (its Raw field must be filled out). func (c Client) CreateCertificate(ctx context.Context, csr *x509.CertificateRequest, validityDays int) (CertificateObject, error) { payload := struct { CertificateDomains string `json:"certificate_domains"` CertificateCSR string `json:"certificate_csr"` CertificateValidityDays int `json:"certificate_validity_days,omitempty"` StrictDomains int `json:"strict_domains,omitempty"` ReplacementForCertificate string `json:"replacement_for_certificate,omitempty"` }{ CertificateDomains: strings.Join(identifiersFromCSR(csr), ","), CertificateCSR: csr2pem(csr.Raw), CertificateValidityDays: validityDays, StrictDomains: 1, } var result CertificateObject if err := c.httpPost(ctx, "/certificates", nil, payload, &result); err != nil { return CertificateObject{}, err } return result, nil } // VerifyIdentifiers tells ZeroSSL that you are ready to prove control over your domain/IP using the method specified. // The credentials from CreateCertificate must be used to verify identifiers. At least one email is required if using // email verification method. func (c Client) VerifyIdentifiers(ctx context.Context, certificateID string, method VerificationMethod, emails []string) (CertificateObject, error) { payload := struct { ValidationMethod VerificationMethod `json:"validation_method"` ValidationEmail string `json:"validation_email,omitempty"` }{ ValidationMethod: method, } if method == EmailVerification && len(emails) > 0 { payload.ValidationEmail = strings.Join(emails, ",") } endpoint := fmt.Sprintf("/certificates/%s/challenges", url.QueryEscape(certificateID)) var result CertificateObject if err := c.httpPost(ctx, endpoint, nil, payload, &result); err != nil { return CertificateObject{}, err } return result, nil } // DownloadCertificateFile writes the certificate bundle as a zip file to the provided output writer. func (c Client) DownloadCertificateFile(ctx context.Context, certificateID string, includeCrossSigned bool, output io.Writer) error { endpoint := fmt.Sprintf("/certificates/%s/download", url.QueryEscape(certificateID)) qs := url.Values{} if includeCrossSigned { qs.Set("include_cross_signed", "1") } url := c.url(endpoint, qs) r, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } resp, err := c.httpClient().Do(r) if err != nil { return err } defer resp.Body.Close() //nolint:errcheck if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected status code: HTTP %d", resp.StatusCode) } if _, err := io.Copy(output, resp.Body); err != nil { return err } return nil } func (c Client) DownloadCertificate(ctx context.Context, certificateID string, includeCrossSigned bool) (CertificateBundle, error) { endpoint := fmt.Sprintf("/certificates/%s/download/return", url.QueryEscape(certificateID)) qs := url.Values{} if includeCrossSigned { qs.Set("include_cross_signed", "1") } var result CertificateBundle if err := c.httpGet(ctx, endpoint, qs, &result); err != nil { return CertificateBundle{}, err } return result, nil } func (c Client) GetCertificate(ctx context.Context, certificateID string) (CertificateObject, error) { endpoint := fmt.Sprintf("/certificates/%s", url.QueryEscape(certificateID)) var result CertificateObject if err := c.httpGet(ctx, endpoint, nil, &result); err != nil { return CertificateObject{}, err } return result, nil } // ListCertificateParameters specifies how to search or list certificates on the account. // An empty set of parameters will return no results. type ListCertificatesParameters struct { // Return certificates with this status. Status string // Return these types of certificates. Type string // The CommonName or SAN. Search string // The page number. Default: 1 Page int // How many per page. Default: 100 Limit int } func (c Client) ListCertificates(ctx context.Context, params ListCertificatesParameters) (CertificateList, error) { qs := url.Values{} if params.Status != "" { qs.Set("certificate_status", params.Status) } if params.Type != "" { qs.Set("certificate_type", params.Type) } if params.Search != "" { qs.Set("search", params.Search) } if params.Limit != 0 { qs.Set("limit", strconv.Itoa(params.Limit)) } if params.Page != 0 { qs.Set("page", strconv.Itoa(params.Page)) } var result CertificateList if err := c.httpGet(ctx, "/certificates", qs, &result); err != nil { return CertificateList{}, err } return result, nil } func (c Client) VerificationStatus(ctx context.Context, certificateID string) (ValidationStatus, error) { endpoint := fmt.Sprintf("/certificates/%s/status", url.QueryEscape(certificateID)) var result ValidationStatus if err := c.httpGet(ctx, endpoint, nil, &result); err != nil { return ValidationStatus{}, err } return result, nil } func (c Client) ResendVerificationEmail(ctx context.Context, certificateID string) error { endpoint := fmt.Sprintf("/certificates/%s/challenges/email", url.QueryEscape(certificateID)) var result struct { Success anyBool `json:"success"` } if err := c.httpGet(ctx, endpoint, nil, &result); err != nil { return err } if !result.Success { return fmt.Errorf("got %v without any error status", result) } return nil } // Only revoke a certificate if the private key is compromised, the certificate was a mistake, or // the identifiers are no longer in use. Do not revoke a certificate when renewing it. func (c Client) RevokeCertificate(ctx context.Context, certificateID string, reason RevocationReason) error { endpoint := fmt.Sprintf("/certificates/%s/revoke", url.QueryEscape(certificateID)) qs := url.Values{"reason": []string{string(reason)}} var result struct { Success anyBool `json:"success"` } if err := c.httpPost(ctx, endpoint, qs, nil, &result); err != nil { return err } if !result.Success { return fmt.Errorf("got %v without any error status", result) } return nil } // CancelCertificate cancels a certificate that has not been issued yet (is in draft or pending_validation state). func (c Client) CancelCertificate(ctx context.Context, certificateID string) error { endpoint := fmt.Sprintf("/certificates/%s/cancel", url.QueryEscape(certificateID)) var result struct { Success anyBool `json:"success"` } if err := c.httpPost(ctx, endpoint, nil, nil, &result); err != nil { return err } if !result.Success { return fmt.Errorf("got %v without any error status", result) } return nil } // ValidateCSR sends the CSR to ZeroSSL for validation. Pass in the ASN.1 DER-encoded bytes; // this is found in x509.CertificateRequest.Raw after calling x5p9.CreateCertificateRequest. func (c Client) ValidateCSR(ctx context.Context, csrASN1DER []byte) error { payload := struct { CSR string `json:"csr"` }{ CSR: csr2pem(csrASN1DER), } var result struct { Valid bool `json:"valid"` Error any `json:"error"` } if err := c.httpPost(ctx, "/validation/csr", nil, payload, &result); err != nil { return err } if !result.Valid { return fmt.Errorf("invalid CSR: %v", result.Error) } return nil } func (c Client) GenerateEABCredentials(ctx context.Context) (keyID, hmacKey string, err error) { var result struct { EABKID string `json:"eab_kid"` EABHMACKey string `json:"eab_hmac_key"` } err = c.httpPost(ctx, "/acme/eab-credentials", nil, nil, &result) if err != nil { return } return result.EABKID, result.EABHMACKey, err } golang-github-caddyserver-zerossl-0.1.5/go.mod000066400000000000000000000000611515320643000213560ustar00rootroot00000000000000module github.com/caddyserver/zerossl go 1.21.0 golang-github-caddyserver-zerossl-0.1.5/go.sum000066400000000000000000000000001515320643000213740ustar00rootroot00000000000000golang-github-caddyserver-zerossl-0.1.5/models.go000066400000000000000000000067261515320643000221000ustar00rootroot00000000000000package zerossl import "fmt" type APIError struct { Success *anyBool `json:"success,omitempty"` ErrorInfo struct { Code int `json:"code"` Type string `json:"type"` // for domain verification only; each domain is grouped into its // www and non-www variant for CNAME validation, or its URL // for HTTP validation Details map[string]map[string]ValidationError `json:"details"` } `json:"error"` // added after decoding so we can make a more descriptive error message, // but only after stripping credentials from it URL string `json:"-"` } func (ae APIError) Error() string { if ae.ErrorInfo.Code == 0 && ae.ErrorInfo.Type == "" && len(ae.ErrorInfo.Details) == 0 { return "" } return fmt.Sprintf("API error %d (%s): %s (details=%v)", ae.ErrorInfo.Code, ae.ErrorInfo.Type, ae.URL, ae.ErrorInfo.Details) } type ValidationError struct { CNAMEValidationError HTTPValidationError } type CNAMEValidationError struct { CNAMEFound int `json:"cname_found"` RecordCorrect int `json:"record_correct"` TargetHost string `json:"target_host"` TargetRecord string `json:"target_record"` ActualRecord string `json:"actual_record"` } type HTTPValidationError struct { FileFound int `json:"file_found"` Error bool `json:"error"` ErrorSlug string `json:"error_slug"` ErrorInfo string `json:"error_info"` } type CertificateObject struct { ID string `json:"id"` // "certificate hash" Type string `json:"type"` CommonName string `json:"common_name"` AdditionalDomains string `json:"additional_domains"` Created string `json:"created"` Expires string `json:"expires"` Status string `json:"status"` ValidationType *string `json:"validation_type,omitempty"` ValidationEmails *string `json:"validation_emails,omitempty"` ReplacementFor string `json:"replacement_for,omitempty"` FingerprintSHA1 *string `json:"fingerprint_sha1"` BrandValidation any `json:"brand_validation"` Validation *struct { EmailValidation map[string][]string `json:"email_validation,omitempty"` OtherMethods map[string]ValidationObject `json:"other_methods,omitempty"` } `json:"validation,omitempty"` SignatureAlgorithmProperties any `json:"signature_algorithm_properties,omitempty"` // unsure what this is, but fixes #3 } type ValidationObject struct { FileValidationURLHTTP string `json:"file_validation_url_http"` FileValidationURLHTTPS string `json:"file_validation_url_https"` FileValidationContent []string `json:"file_validation_content"` CnameValidationP1 string `json:"cname_validation_p1"` CnameValidationP2 string `json:"cname_validation_p2"` } type CertificateBundle struct { CertificateCrt string `json:"certificate.crt"` CABundleCrt string `json:"ca_bundle.crt"` } type CertificateList struct { TotalCount int `json:"total_count"` ResultCount int `json:"result_count"` Page string `json:"page"` // don't ask me why this is a string Limit int `json:"limit"` ACMEUsageLevel string `json:"acmeUsageLevel"` ACMELocked bool `json:"acmeLocked"` Results []CertificateObject `json:"results"` } type ValidationStatus struct { ValidationCompleted int `json:"validation_completed"` Details map[string]struct { Method string `json:"method"` Status string `json:"status"` } `json:"details"` } golang-github-caddyserver-zerossl-0.1.5/zerossl.go000066400000000000000000000044021515320643000223030ustar00rootroot00000000000000// Package zerossl implements the ZeroSSL REST API. // See the API documentation on the ZeroSSL website: https://zerossl.com/documentation/api/ package zerossl import ( "crypto/x509" "encoding/base64" "fmt" ) // The base URL to the ZeroSSL API. const BaseURL = "https://api.zerossl.com" // ListAllCertificates returns parameters that lists all the certificates on the account; // be sure to set Page and Limit if paginating. func ListAllCertificates() ListCertificatesParameters { return ListCertificatesParameters{ Status: "draft,pending_validation,issued,cancelled,revoked,expired", } } func identifiersFromCSR(csr *x509.CertificateRequest) []string { var identifiers []string if csr.Subject.CommonName != "" { // deprecated for like 20 years, but oh well identifiers = append(identifiers, csr.Subject.CommonName) } identifiers = append(identifiers, csr.DNSNames...) identifiers = append(identifiers, csr.EmailAddresses...) for _, ip := range csr.IPAddresses { identifiers = append(identifiers, ip.String()) } for _, uri := range csr.URIs { identifiers = append(identifiers, uri.String()) } return identifiers } func csr2pem(csrASN1DER []byte) string { return fmt.Sprintf("-----BEGIN CERTIFICATE REQUEST-----\n%s\n-----END CERTIFICATE REQUEST-----", base64.StdEncoding.EncodeToString(csrASN1DER)) } // VerificationMethod represents a way of verifying identifiers with ZeroSSL. type VerificationMethod string // Verification methods. const ( EmailVerification VerificationMethod = "EMAIL" CNAMEVerification VerificationMethod = "CNAME_CSR_HASH" HTTPVerification VerificationMethod = "HTTP_CSR_HASH" HTTPSVerification VerificationMethod = "HTTPS_CSR_HASH" ) // RevocationReason represents various reasons for revoking a certificate. type RevocationReason string const ( UnspecifiedReason RevocationReason = "unspecified" // default KeyCompromise RevocationReason = "keyCompromise" // lost control of private key AffiliationChanged RevocationReason = "affiliationChanged" // identify information changed Superseded RevocationReason = "Superseded" // certificate replaced -- do not revoke for this reason, however CessationOfOperation RevocationReason = "cessationOfOperation" // domains are no longer in use )