maidn-cli/internal/cloudflare/client.go

123 lines
3.9 KiB
Go

package cloudflare
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
const apiURL = "https://api.cloudflare.com/client/v4"
// API is the narrow Cloudflare DNS boundary used by route commands.
type API interface {
EnsureCNAME(context.Context, string, string, string) error
DeleteCNAME(context.Context, string, string, string) error
}
type dnsRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Content string `json:"content"`
Proxied bool `json:"proxied"`
}
type Client struct {
baseURL string
httpClient *http.Client
token string
}
func NewClient(token string) *Client {
return &Client{baseURL: apiURL, httpClient: &http.Client{Timeout: 15 * time.Second}, token: token}
}
func (c *Client) EnsureCNAME(ctx context.Context, zoneID, hostname, tunnelID string) error {
records, err := c.cnameRecords(ctx, zoneID, hostname)
if err != nil {
return err
}
target := tunnelTarget(tunnelID)
if len(records) == 0 {
body, err := json.Marshal(struct {
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Proxied bool `json:"proxied"`
}{Type: "CNAME", Name: hostname, Content: target, Proxied: true})
if err != nil {
return errors.New("encode Cloudflare DNS record")
}
var created dnsRecord
return c.request(ctx, http.MethodPost, "/zones/"+url.PathEscape(zoneID)+"/dns_records", body, &created)
}
if len(records) != 1 || !strings.EqualFold(records[0].Name, hostname) || !strings.EqualFold(records[0].Content, target) || !records[0].Proxied {
return errors.New("existing Cloudflare DNS record does not match the managed tunnel route")
}
return nil
}
func (c *Client) DeleteCNAME(ctx context.Context, zoneID, hostname, tunnelID string) error {
records, err := c.cnameRecords(ctx, zoneID, hostname)
if err != nil {
return err
}
if len(records) == 0 {
return nil
}
target := tunnelTarget(tunnelID)
if len(records) != 1 || !strings.EqualFold(records[0].Name, hostname) || !strings.EqualFold(records[0].Content, target) || !records[0].Proxied {
return errors.New("existing Cloudflare DNS record does not match the managed tunnel route")
}
var deleted dnsRecord
return c.request(ctx, http.MethodDelete, "/zones/"+url.PathEscape(zoneID)+"/dns_records/"+url.PathEscape(records[0].ID), nil, &deleted)
}
func (c *Client) cnameRecords(ctx context.Context, zoneID, hostname string) ([]dnsRecord, error) {
var records []dnsRecord
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records?" + url.Values{"type": {"CNAME"}, "name": {hostname}}.Encode()
err := c.request(ctx, http.MethodGet, path, nil, &records)
return records, err
}
func (c *Client) request(ctx context.Context, method, path string, body []byte, result any) error {
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return errors.New("create Cloudflare API request")
}
request.Header.Set("Authorization", "Bearer "+c.token)
request.Header.Set("Content-Type", "application/json")
client := c.httpClient
if client == nil {
client = &http.Client{Timeout: 15 * time.Second}
}
response, err := client.Do(request)
if err != nil {
return errors.New("call Cloudflare API")
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("Cloudflare API returned HTTP %d", response.StatusCode)
}
var envelope struct {
Success bool `json:"success"`
Result json.RawMessage `json:"result"`
}
if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil || !envelope.Success {
return errors.New("Cloudflare API returned an invalid response")
}
if err := json.Unmarshal(envelope.Result, result); err != nil {
return errors.New("Cloudflare API returned an invalid result")
}
return nil
}
func tunnelTarget(tunnelID string) string {
return tunnelID + ".cfargotunnel.com"
}