Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions flaps/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,12 @@ const (
metadataDel
regionsGet
placementPost
certificateList
certificateCreateACME
certificateCreateCustom
certificateGet
certificateCheck
certificateDelete
certificateDeleteACME
certificateDeleteCustom
)
127 changes: 127 additions & 0 deletions flaps/flaps_certificates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package flaps

import (
"context"
"fmt"
"net/http"
"net/url"

fly "github.com/superfly/fly-go"
)

func (f *Client) sendRequestCertificates(ctx context.Context, appName, method, endpoint string, in, out any, headers map[string][]string) error {
endpoint = fmt.Sprintf("/apps/%s/certificates%s", url.PathEscape(appName), endpoint)
return f._sendRequest(ctx, method, endpoint, in, out, headers)
}

type ListCertificatesOpts struct {
Limit int
Cursor string
Filter string
}

func (f *Client) ListCertificates(ctx context.Context, appName string, opts *ListCertificatesOpts) (*fly.ListCertificatesResponse, error) {
ctx = contextWithAction(ctx, certificateList)

params := url.Values{}
if opts != nil {
if opts.Limit > 0 {
params.Set("limit", fmt.Sprintf("%d", opts.Limit))
}
if opts.Cursor != "" {
params.Set("cursor", opts.Cursor)
}
if opts.Filter != "" {
params.Set("filter", opts.Filter)
}
}

endpoint := ""
if len(params) > 0 {
endpoint = "?" + params.Encode()
}

out := new(fly.ListCertificatesResponse)
if err := f.sendRequestCertificates(ctx, appName, http.MethodGet, endpoint, nil, out, nil); err != nil {
return nil, fmt.Errorf("failed to list certificates: %w", err)
}
return out, nil
}

// Add a hostname and start ACME issuance.
func (f *Client) CreateACMECertificate(ctx context.Context, appName string, req fly.CreateCertificateRequest) (*fly.CertificateDetailResponse, error) {
ctx = contextWithAction(ctx, certificateCreateACME)

out := new(fly.CertificateDetailResponse)
if err := f.sendRequestCertificates(ctx, appName, http.MethodPost, "/acme", req, out, nil); err != nil {
return nil, fmt.Errorf("failed to create ACME certificate: %w", err)
}
return out, nil
}

// Add a custom certificate. Will not start ACME issuance if not already present.
func (f *Client) CreateCustomCertificate(ctx context.Context, appName string, req fly.ImportCertificateRequest) (*fly.CertificateDetailResponse, error) {
ctx = contextWithAction(ctx, certificateCreateCustom)

out := new(fly.CertificateDetailResponse)
if err := f.sendRequestCertificates(ctx, appName, http.MethodPost, "/custom", req, out, nil); err != nil {
return nil, fmt.Errorf("failed to import certificate: %w", err)
}
return out, nil
}

func (f *Client) GetCertificate(ctx context.Context, appName, hostname string) (*fly.CertificateDetailResponse, error) {
ctx = contextWithAction(ctx, certificateGet)

out := new(fly.CertificateDetailResponse)
endpoint := fmt.Sprintf("/%s", url.PathEscape(hostname))
if err := f.sendRequestCertificates(ctx, appName, http.MethodGet, endpoint, nil, out, nil); err != nil {
return nil, fmt.Errorf("failed to get certificate: %w", err)
}
return out, nil
}

// Triggers DNS validation + ACME issuance if required.
func (f *Client) CheckCertificate(ctx context.Context, appName, hostname string) (*fly.CertificateDetailResponse, error) {
ctx = contextWithAction(ctx, certificateCheck)

out := new(fly.CertificateDetailResponse)
endpoint := fmt.Sprintf("/%s/check", url.PathEscape(hostname))
if err := f.sendRequestCertificates(ctx, appName, http.MethodPost, endpoint, nil, out, nil); err != nil {
return nil, fmt.Errorf("failed to check certificate: %w", err)
}
return out, nil
}

// Removes hostname and all certificates.
func (f *Client) DeleteCertificate(ctx context.Context, appName, hostname string) error {
ctx = contextWithAction(ctx, certificateDelete)

endpoint := fmt.Sprintf("/%s", url.PathEscape(hostname))
if err := f.sendRequestCertificates(ctx, appName, http.MethodDelete, endpoint, nil, nil, nil); err != nil {
return fmt.Errorf("failed to delete certificate: %w", err)
}
return nil
}

// Removes ACME certificates and stops renewals, leaving a custom certificate in place.
func (f *Client) DeleteACMECertificate(ctx context.Context, appName, hostname string) error {
ctx = contextWithAction(ctx, certificateDeleteACME)

endpoint := fmt.Sprintf("/%s/acme", url.PathEscape(hostname))
if err := f.sendRequestCertificates(ctx, appName, http.MethodDelete, endpoint, nil, nil, nil); err != nil {
return fmt.Errorf("failed to stop ACME certificate: %w", err)
}
return nil
}

// Removes a custom certificate, leaving the ACME certificates in place.
func (f *Client) DeleteCustomCertificate(ctx context.Context, appName, hostname string) error {
ctx = contextWithAction(ctx, certificateDeleteCustom)

endpoint := fmt.Sprintf("/%s/custom", url.PathEscape(hostname))
if err := f.sendRequestCertificates(ctx, appName, http.MethodDelete, endpoint, nil, nil, nil); err != nil {
return fmt.Errorf("failed to delete custom certificate: %w", err)
}
return nil
}
12 changes: 10 additions & 2 deletions flaps/flapsaction_string.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

104 changes: 104 additions & 0 deletions types.go
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,110 @@ type DeleteCertificatePayload struct {
Certificate AppCertificate
}

type CreateCertificateRequest struct {
Hostname string `json:"hostname"`
}

type ImportCertificateRequest struct {
Hostname string `json:"hostname"`
Fullchain string `json:"fullchain"`
PrivateKey string `json:"private_key"`
}

type ListCertificatesResponse struct {
Certificates []CertificateSummary `json:"certificates"`
NextCursor string `json:"next_cursor,omitempty"`
TotalCount int `json:"total_count,omitempty"`
}

type CertificateSummary struct {
Hostname string `json:"hostname"`
Status string `json:"status"`
DNSProvider string `json:"dns_provider,omitempty"`
AcmeDNSConfigured bool `json:"acme_dns_configured"`
AcmeALPNConfigured bool `json:"acme_alpn_configured"`
AcmeHTTPConfigured bool `json:"acme_http_configured"`
OwnershipTxtConfigured bool `json:"ownership_txt_configured"`
Configured bool `json:"configured"`
AcmeRequested bool `json:"acme_requested"`
HasCustomCertificate bool `json:"has_custom_certificate"`
HasFlyCertificate bool `json:"has_fly_certificate"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

type CertificateValidation struct {
DNSConfigured bool `json:"dns_configured"`
ALPNConfigured bool `json:"alpn_configured"`
HTTPConfigured bool `json:"http_configured"`
OwnershipTxtConfigured bool `json:"ownership_txt_configured"`
}

type ACMEChallengeRequirement struct {
Name string `json:"name"`
Target string `json:"target"`
}

type OwnershipRequirement struct {
Name string `json:"name"`
AppValue string `json:"app_value"`
OrgValue string `json:"org_value"`
}

type DNSRequirements struct {
A []string `json:"a"`
AAAA []string `json:"aaaa"`
CNAME string `json:"cname"`
ACMEChallenge ACMEChallengeRequirement `json:"acme_challenge"`
Ownership OwnershipRequirement `json:"ownership"`
}

type DNSRecords struct {
A []string `json:"a"`
AAAA []string `json:"aaaa"`
CNAME []string `json:"cname"`
ResolvedAddresses []string `json:"resolved_addresses"`
SOA *string `json:"soa"`
ACMEChallengeCNAME *string `json:"acme_challenge_cname"`
OwnershipTXT *string `json:"ownership_txt"`
}

type CertificateDetailResponse struct {
Hostname string `json:"hostname"`
Configured bool `json:"configured"`
AcmeRequested bool `json:"acme_requested"`
Status string `json:"status"`
DNSProvider string `json:"dns_provider,omitempty"`
RateLimitedUntil *time.Time `json:"rate_limited_until,omitempty"`
Certificates []CertificateDetail `json:"certificates"`
Validation CertificateValidation `json:"validation"`
DNSRequirements DNSRequirements `json:"dns_requirements"`
DNSRecords *DNSRecords `json:"dns_records,omitempty"`
ValidationErrors []ValidationError `json:"validation_errors,omitempty"`
}

type CertificateDetail struct {
Source string `json:"source"`
Status string `json:"status"`
CreatedAt *time.Time `json:"created_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Issuer string `json:"issuer,omitempty"`
Issued []IssuedCertInfo `json:"issued"`
}

type IssuedCertInfo struct {
Type string `json:"type"`
ExpiresAt time.Time `json:"expires_at"`
CertificateAuthority string `json:"certificate_authority,omitempty"`
}

type ValidationError struct {
Code *string `json:"code"`
Message string `json:"message"`
Remediation string `json:"remediation,omitempty"`
Timestamp time.Time `json:"timestamp"`
}

type AllocateIPAddressInput struct {
AppID string `json:"appId"`
Type string `json:"type"`
Expand Down