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