diff --git a/pkg/services/cloudflare_service.go b/pkg/services/cloudflare_service.go index c18b084..4dccb9b 100644 --- a/pkg/services/cloudflare_service.go +++ b/pkg/services/cloudflare_service.go @@ -79,8 +79,9 @@ func (c *CloudflareClient) get(path string, dest interface{}) error { // ─── Tipos de error ────────────────────────────────────────────────────────── type CFError struct { - Code int `json:"code"` - Message string `json:"message"` + Code int `json:"code"` + Message string `json:"message"` + DocumentationURL string `json:"documentation_url,omitempty"` } // ─── Tipos de respuesta ────────────────────────────────────────────────────── @@ -94,18 +95,26 @@ type CFUser struct { Username string `json:"username"` } +// CFZoneAccount representa la cuenta propietaria de la zona. +type CFZoneAccount struct { + ID string `json:"id"` + Name string `json:"name"` +} + // CFZone representa una zona (dominio) en Cloudflare. type CFZone struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - Paused bool `json:"paused"` - Type string `json:"type"` - NameServers []string `json:"name_servers"` - OriginalNS []string `json:"original_name_servers"` - ModifiedOn string `json:"modified_on"` - ActivatedOn string `json:"activated_on"` - Plan CFPlan `json:"plan"` + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Paused bool `json:"paused"` + Type string `json:"type"` + NameServers []string `json:"name_servers"` + OriginalNS []string `json:"original_name_servers"` + CreatedOn string `json:"created_on"` + ModifiedOn string `json:"modified_on"` + ActivatedOn string `json:"activated_on"` + Account CFZoneAccount `json:"account"` + Plan CFPlan `json:"plan"` } // CFPlan representa el plan de una zona. @@ -114,18 +123,30 @@ type CFPlan struct { Name string `json:"name"` } +// CFDNSRecordSettings configuraciones adicionales de un registro DNS. +type CFDNSRecordSettings struct { + IPv4Only bool `json:"ipv4_only,omitempty"` + IPv6Only bool `json:"ipv6_only,omitempty"` +} + // CFDNSRecord representa un registro DNS. type CFDNSRecord struct { - ID string `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Content string `json:"content"` - Proxied bool `json:"proxied"` - Proxiable bool `json:"proxiable"` - TTL int `json:"ttl"` - Priority int `json:"priority,omitempty"` - CreatedOn string `json:"created_on"` - ModifiedOn string `json:"modified_on"` + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + Comment string `json:"comment,omitempty"` + Tags []string `json:"tags,omitempty"` + Settings CFDNSRecordSettings `json:"settings,omitempty"` + PrivateRouting bool `json:"private_routing,omitempty"` + Proxied bool `json:"proxied"` + Proxiable bool `json:"proxiable"` + TTL int `json:"ttl"` + Priority int `json:"priority,omitempty"` + CreatedOn string `json:"created_on"` + ModifiedOn string `json:"modified_on"` + CommentModifiedOn string `json:"comment_modified_on,omitempty"` + TagsModifiedOn string `json:"tags_modified_on,omitempty"` } // CFSSLStatus representa el estado SSL de una zona. @@ -222,24 +243,105 @@ func (c *CloudflareClient) GetTokenDetail(tokenID string) (*CFTokenDetail, error return &detail, nil } -// GetZones lista todas las zonas de la cuenta. -func (c *CloudflareClient) GetZones() ([]CFZone, error) { - var zones []CFZone - // pagina 1, 50 por página (máximo) - if err := c.get("/zones?per_page=50&page=1", &zones); err != nil { - return nil, err - } - return zones, nil +// cfResultInfo contiene metadatos de paginación de la API de Cloudflare. +type cfResultInfo struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + TotalPages int `json:"total_pages"` + Count int `json:"count"` + TotalCount int `json:"total_count"` } -// GetDNSRecords devuelve los registros DNS de una zona. -func (c *CloudflareClient) GetDNSRecords(zoneID string) ([]CFDNSRecord, error) { - var records []CFDNSRecord - path := fmt.Sprintf("/zones/%s/dns_records?per_page=100&page=1", zoneID) - if err := c.get(path, &records); err != nil { - return nil, err +// getWithInfo realiza un GET y decodifica también result_info para paginación. +func (c *CloudflareClient) getWithInfo(path string, dest interface{}) (cfResultInfo, error) { + req, err := http.NewRequest(http.MethodGet, cloudflareBaseURL+path, nil) + if err != nil { + return cfResultInfo{}, fmt.Errorf("cloudflare: crear request: %w", err) } - return records, nil + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return cfResultInfo{}, fmt.Errorf("cloudflare: ejecutar request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return cfResultInfo{}, fmt.Errorf("cloudflare: leer body: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return cfResultInfo{}, fmt.Errorf("cloudflare: status %d – %s", resp.StatusCode, string(body)) + } + + var wrapper struct { + Success bool `json:"success"` + Errors []CFError `json:"errors"` + Result json.RawMessage `json:"result"` + ResultInfo cfResultInfo `json:"result_info"` + } + if err := json.Unmarshal(body, &wrapper); err != nil { + return cfResultInfo{}, fmt.Errorf("cloudflare: decodificar envelope: %w", err) + } + if !wrapper.Success { + if len(wrapper.Errors) > 0 { + return cfResultInfo{}, fmt.Errorf("cloudflare API error %d: %s", wrapper.Errors[0].Code, wrapper.Errors[0].Message) + } + return cfResultInfo{}, fmt.Errorf("cloudflare: respuesta no exitosa") + } + if dest != nil && wrapper.Result != nil { + if err := json.Unmarshal(wrapper.Result, dest); err != nil { + return cfResultInfo{}, fmt.Errorf("cloudflare: decodificar result: %w", err) + } + } + return wrapper.ResultInfo, nil +} + +// GetZones lista todas las zonas de la cuenta, iterando páginas si es necesario. +// Si el cliente fue creado con accountID, filtra por esa cuenta. +func (c *CloudflareClient) GetZones() ([]CFZone, error) { + const perPage = 50 + var all []CFZone + + accountFilter := "" + if c.accountID != "" { + accountFilter = "&account.id=" + c.accountID + } + + for page := 1; ; page++ { + path := fmt.Sprintf("/zones?per_page=%d&page=%d%s", perPage, page, accountFilter) + var zones []CFZone + info, err := c.getWithInfo(path, &zones) + if err != nil { + return nil, err + } + all = append(all, zones...) + if page >= info.TotalPages || info.TotalPages == 0 { + break + } + } + return all, nil +} + +// GetDNSRecords devuelve todos los registros DNS de una zona, paginando automáticamente. +func (c *CloudflareClient) GetDNSRecords(zoneID string) ([]CFDNSRecord, error) { + const perPage = 5000 + var all []CFDNSRecord + + for page := 1; ; page++ { + path := fmt.Sprintf("/zones/%s/dns_records?per_page=%d&page=%d", zoneID, perPage, page) + var records []CFDNSRecord + info, err := c.getWithInfo(path, &records) + if err != nil { + return nil, err + } + all = append(all, records...) + if page >= info.TotalPages || info.TotalPages == 0 { + break + } + } + return all, nil } // GetSSLCertificates devuelve los certificate packs de una zona. @@ -328,12 +430,15 @@ func (c *CloudflareClient) doRequest(method, path string, payload interface{}, d // CFDNSRecordInput es el payload para crear o actualizar un registro DNS. type CFDNSRecordInput struct { - Type string `json:"type"` // A, AAAA, CNAME, TXT, MX, NS, SRV, CAA… - Name string `json:"name"` // Nombre del registro (ej. "www" o "@") - Content string `json:"content"` // Valor del registro - TTL int `json:"ttl"` // 1 = automático, o segundos (min 60) - Proxied bool `json:"proxied"` // true = nube naranja - Priority int `json:"priority,omitempty"` // Solo para MX / SRV + Type string `json:"type"` // A, AAAA, CNAME, TXT, MX, NS, SRV, CAA… + Name string `json:"name"` // Nombre del registro (ej. "www" o "@") + Content string `json:"content"` // Valor del registro + TTL int `json:"ttl"` // 1 = automático, o segundos (min 60) + Proxied bool `json:"proxied"` // true = nube naranja + Priority int `json:"priority,omitempty"` // Solo para MX / SRV + Comment string `json:"comment,omitempty"` // Comentario descriptivo + Tags []string `json:"tags,omitempty"` // Etiquetas (ej. ["owner:team"]) + Settings *CFDNSRecordSettings `json:"settings,omitempty"` // Configuraciones adicionales } // ─── DNS CRUD ────────────────────────────────────────────────────────────────