// Package cloudflare provides a Cloudflare DNS adapter implementing port.DNSProvider. package cloudflare import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" ) // Ensure Client implements DNSProvider. var _ port.DNSProvider = (*Client)(nil) const apiBase = "https://api.cloudflare.com/client/v4" // Client is a Cloudflare DNS API client adapter. type Client struct { apiToken string zoneID string zoneName string // e.g., "threesix.ai" http *http.Client } // NewClient creates a new Cloudflare DNS client. // apiToken is a Cloudflare API token with DNS edit permissions // zoneID is the Cloudflare zone ID for the domain // zoneName is the domain name (e.g., "threesix.ai") func NewClient(apiToken, zoneID, zoneName string) *Client { return &Client{ apiToken: apiToken, zoneID: zoneID, zoneName: zoneName, http: &http.Client{ Timeout: 30 * time.Second, }, } } // CreateRecord creates a DNS record. func (c *Client) CreateRecord(ctx context.Context, record domain.DNSRecord) (*domain.DNSRecord, error) { // Normalize name: if just subdomain, append zone name name := c.normalizeName(record.Name) body := map[string]interface{}{ "type": record.Type, "name": name, "content": record.Content, "ttl": record.TTL, "proxied": record.Proxied, } if record.Priority > 0 { body["priority"] = record.Priority } resp, err := c.doRequest(ctx, "POST", fmt.Sprintf("/zones/%s/dns_records", c.zoneID), body) if err != nil { return nil, fmt.Errorf("failed to create DNS record: %w", err) } var result cfResponse if err := json.Unmarshal(resp, &result); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } if !result.Success { return nil, &CloudflareError{Errors: result.Errors} } return recordFromCF(result.Result), nil } // UpdateRecord updates an existing DNS record by ID. func (c *Client) UpdateRecord(ctx context.Context, recordID string, record domain.DNSRecord) (*domain.DNSRecord, error) { name := c.normalizeName(record.Name) body := map[string]interface{}{ "type": record.Type, "name": name, "content": record.Content, "ttl": record.TTL, "proxied": record.Proxied, } if record.Priority > 0 { body["priority"] = record.Priority } resp, err := c.doRequest(ctx, "PUT", fmt.Sprintf("/zones/%s/dns_records/%s", c.zoneID, recordID), body) if err != nil { return nil, fmt.Errorf("failed to update DNS record: %w", err) } var result cfResponse if err := json.Unmarshal(resp, &result); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } if !result.Success { return nil, &CloudflareError{Errors: result.Errors} } return recordFromCF(result.Result), nil } // DeleteRecord removes a DNS record by ID. func (c *Client) DeleteRecord(ctx context.Context, recordID string) error { _, err := c.doRequest(ctx, "DELETE", fmt.Sprintf("/zones/%s/dns_records/%s", c.zoneID, recordID), nil) if err != nil { return fmt.Errorf("failed to delete DNS record: %w", err) } return nil } // DeleteRecordByName removes a DNS record by type and name. func (c *Client) DeleteRecordByName(ctx context.Context, recordType, name string) error { record, err := c.FindRecord(ctx, recordType, name) if err != nil { return err } if record == nil { return nil // Already doesn't exist } return c.DeleteRecord(ctx, record.ID) } // GetRecord returns a single record by ID. func (c *Client) GetRecord(ctx context.Context, recordID string) (*domain.DNSRecord, error) { resp, err := c.doRequest(ctx, "GET", fmt.Sprintf("/zones/%s/dns_records/%s", c.zoneID, recordID), nil) if err != nil { return nil, fmt.Errorf("failed to get DNS record: %w", err) } var result cfResponse if err := json.Unmarshal(resp, &result); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } if !result.Success { return nil, &CloudflareError{Errors: result.Errors} } return recordFromCF(result.Result), nil } // ListRecords returns all records in the zone. func (c *Client) ListRecords(ctx context.Context, recordType string) ([]*domain.DNSRecord, error) { path := fmt.Sprintf("/zones/%s/dns_records?per_page=100", c.zoneID) if recordType != "" { path += "&type=" + recordType } resp, err := c.doRequest(ctx, "GET", path, nil) if err != nil { return nil, fmt.Errorf("failed to list DNS records: %w", err) } var result cfListResponse if err := json.Unmarshal(resp, &result); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } if !result.Success { return nil, &CloudflareError{Errors: result.Errors} } records := make([]*domain.DNSRecord, len(result.Result)) for i, r := range result.Result { records[i] = recordFromCFMap(r) } return records, nil } // FindRecord finds a record by type and name. func (c *Client) FindRecord(ctx context.Context, recordType, name string) (*domain.DNSRecord, error) { normalizedName := c.normalizeName(name) path := fmt.Sprintf("/zones/%s/dns_records?type=%s&name=%s", c.zoneID, recordType, normalizedName) resp, err := c.doRequest(ctx, "GET", path, nil) if err != nil { return nil, fmt.Errorf("failed to find DNS record: %w", err) } var result cfListResponse if err := json.Unmarshal(resp, &result); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } if !result.Success { return nil, &CloudflareError{Errors: result.Errors} } if len(result.Result) == 0 { return nil, nil } return recordFromCFMap(result.Result[0]), nil } // UpsertRecord creates or updates a DNS record. // If a record with the same type and name exists, it updates it. // Otherwise, it creates a new record. // Returns the created or updated record. // Handles race conditions with retry logic. func (c *Client) UpsertRecord(ctx context.Context, record domain.DNSRecord) (*domain.DNSRecord, error) { const maxRetries = 3 for attempt := 0; attempt < maxRetries; attempt++ { // Try to find existing record existing, err := c.FindRecord(ctx, record.Type, record.Name) if err != nil { return nil, fmt.Errorf("failed to check for existing record: %w", err) } if existing != nil { // Update existing record updated, err := c.UpdateRecord(ctx, existing.ID, record) if err != nil { return nil, fmt.Errorf("failed to update existing record: %w", err) } return updated, nil } // Create new record created, err := c.CreateRecord(ctx, record) if err == nil { return created, nil } // Handle race condition: record was created between Find and Create if !isRecordExistsError(err) { return nil, fmt.Errorf("failed to create record: %w", err) } // Race condition detected - retry the whole find-or-create loop // This handles the case where another process created the record } return nil, fmt.Errorf("failed to upsert record after %d attempts due to concurrent modifications", maxRetries) } // isRecordExistsError checks if the error indicates a duplicate record. func isRecordExistsError(err error) bool { if err == nil { return false } // Cloudflare returns "A record with that host already exists" or similar errStr := err.Error() return strings.Contains(errStr, "already exists") || strings.Contains(errStr, "duplicate") } // normalizeName converts a subdomain to full domain name. func (c *Client) normalizeName(name string) string { if name == "@" || name == "" { return c.zoneName } // If already has zone suffix, return as-is if len(name) > len(c.zoneName) && name[len(name)-len(c.zoneName):] == c.zoneName { return name } return name + "." + c.zoneName } // doRequest performs an HTTP request to the Cloudflare API. func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}) ([]byte, error) { var bodyReader io.Reader if body != nil { jsonBody, err := json.Marshal(body) if err != nil { return nil, err } bodyReader = bytes.NewReader(jsonBody) } req, err := http.NewRequestWithContext(ctx, method, apiBase+path, bodyReader) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+c.apiToken) req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode >= 400 { return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) } return respBody, nil } // Cloudflare API response types type cfResponse struct { Success bool `json:"success"` Errors []cfError `json:"errors"` Result map[string]interface{} `json:"result"` } type cfListResponse struct { Success bool `json:"success"` Errors []cfError `json:"errors"` Result []map[string]interface{} `json:"result"` } type cfError struct { Code int `json:"code"` Message string `json:"message"` } // CloudflareError wraps Cloudflare API errors with structured data. type CloudflareError struct { Errors []cfError } func (e *CloudflareError) Error() string { msgs := make([]string, len(e.Errors)) for i, ce := range e.Errors { msgs[i] = fmt.Sprintf("[%d] %s", ce.Code, ce.Message) } return "cloudflare API: " + strings.Join(msgs, "; ") } // HasCode returns true if any of the errors has the given code. func (e *CloudflareError) HasCode(code int) bool { for _, ce := range e.Errors { if ce.Code == code { return true } } return false } // recordFromCF converts a Cloudflare record response to domain.DNSRecord. func recordFromCF(r map[string]interface{}) *domain.DNSRecord { return recordFromCFMap(r) } func recordFromCFMap(r map[string]interface{}) *domain.DNSRecord { record := &domain.DNSRecord{} if id, ok := r["id"].(string); ok { record.ID = id } if t, ok := r["type"].(string); ok { record.Type = t } if name, ok := r["name"].(string); ok { record.Name = name } if content, ok := r["content"].(string); ok { record.Content = content } if ttl, ok := r["ttl"].(float64); ok { record.TTL = int(ttl) } if proxied, ok := r["proxied"].(bool); ok { record.Proxied = proxied } return record }