package cloudflare import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" "time" ) const baseURL = "https://api.cloudflare.com/client/v4" var httpClient = &http.Client{Timeout: 10 * time.Second} type DNSRecord struct { ID string `json:"id"` Type string `json:"type"` Name string `json:"name"` Content string `json:"content"` Proxied bool `json:"proxied"` TTL int `json:"ttl"` } type cfResponse struct { Success bool `json:"success"` Errors []cfError `json:"errors"` Result json.RawMessage `json:"result"` } type cfError struct { Code int `json:"code"` Message string `json:"message"` } func ListDNSRecords(ctx context.Context, apiKey, zoneID string) ([]DNSRecord, error) { url := fmt.Sprintf("%s/zones/%s/dns_records?per_page=500", baseURL, zoneID) body, err := doRequest(ctx, http.MethodGet, url, apiKey, nil) if err != nil { return nil, err } var records []DNSRecord if err := json.Unmarshal(body, &records); err != nil { return nil, fmt.Errorf("unmarshal dns records: %w", err) } return records, nil } type dnsPayload struct { Type string `json:"type"` Name string `json:"name"` Content string `json:"content"` Proxied bool `json:"proxied"` TTL int `json:"ttl"` } func CreateDNSRecord(ctx context.Context, apiKey, zoneID string, rec DNSRecord) (DNSRecord, error) { url := fmt.Sprintf("%s/zones/%s/dns_records", baseURL, zoneID) payload := dnsPayload{ Type: rec.Type, Name: rec.Name, Content: rec.Content, Proxied: rec.Proxied, TTL: 1, // auto } data, err := json.Marshal(payload) if err != nil { return DNSRecord{}, fmt.Errorf("marshal payload: %w", err) } body, err := doRequest(ctx, http.MethodPost, url, apiKey, bytes.NewReader(data)) if err != nil { return DNSRecord{}, err } var created DNSRecord if err := json.Unmarshal(body, &created); err != nil { return DNSRecord{}, fmt.Errorf("unmarshal created record: %w", err) } return created, nil } func UpdateDNSRecord(ctx context.Context, apiKey, zoneID, recordID string, rec DNSRecord) error { url := fmt.Sprintf("%s/zones/%s/dns_records/%s", baseURL, zoneID, recordID) payload := dnsPayload{ Type: rec.Type, Name: rec.Name, Content: rec.Content, Proxied: rec.Proxied, TTL: rec.TTL, } data, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal payload: %w", err) } _, err = doRequest(ctx, http.MethodPut, url, apiKey, bytes.NewReader(data)) return err } type batchPutRecord struct { ID string `json:"id"` Type string `json:"type"` Name string `json:"name"` Content string `json:"content"` Proxied bool `json:"proxied"` TTL int `json:"ttl"` } type batchRequest struct { Puts []batchPutRecord `json:"puts"` } func BatchUpdateDNSRecords(ctx context.Context, apiKey, zoneID string, records []DNSRecord) error { url := fmt.Sprintf("%s/zones/%s/dns_records/batch", baseURL, zoneID) puts := make([]batchPutRecord, len(records)) for i, rec := range records { puts[i] = batchPutRecord{ ID: rec.ID, Type: rec.Type, Name: rec.Name, Content: rec.Content, Proxied: rec.Proxied, TTL: rec.TTL, } } data, err := json.Marshal(batchRequest{Puts: puts}) if err != nil { return fmt.Errorf("marshal batch payload: %w", err) } _, err = doRequest(ctx, http.MethodPost, url, apiKey, bytes.NewReader(data)) return err } func doRequest(ctx context.Context, method, rawURL, apiKey string, body io.Reader) (json.RawMessage, error) { parsed, _ := url.Parse(rawURL) path := parsed.Path if parsed.RawQuery != "" { path = path + "?" + parsed.RawQuery } start := time.Now() slog.Default().Debug("cloudflare request", "method", method, "path", path) req, err := http.NewRequestWithContext(ctx, method, rawURL, body) if err != nil { err = fmt.Errorf("create request: %w", err) slog.Default().Debug("cloudflare request failed", "method", method, "path", path, "error", err) return nil, err } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "application/json") resp, err := httpClient.Do(req) if err != nil { err = fmt.Errorf("do request: %w", err) slog.Default().Debug("cloudflare request failed", "method", method, "path", path, "error", err) return nil, err } defer resp.Body.Close() respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { err = fmt.Errorf("read response: %w", err) slog.Default().Debug("cloudflare request failed", "method", method, "path", path, "error", err) return nil, err } var cfResp cfResponse if err := json.Unmarshal(respBody, &cfResp); err != nil { err = fmt.Errorf("unmarshal response (status %d): %w", resp.StatusCode, err) slog.Default().Debug("cloudflare request failed", "method", method, "path", path, "error", err) return nil, err } if !cfResp.Success { var err error if len(cfResp.Errors) > 0 { err = fmt.Errorf("cloudflare API error: %s (code %d)", cfResp.Errors[0].Message, cfResp.Errors[0].Code) } else { err = fmt.Errorf("cloudflare API error: status %d", resp.StatusCode) } slog.Default().Debug("cloudflare request failed", "method", method, "path", path, "error", err) return nil, err } slog.Default().Debug("cloudflare request done", "method", method, "path", path, "status", resp.StatusCode, "duration_ms", time.Since(start).Milliseconds()) return cfResp.Result, nil }