package services import ( "bytes" "encoding/json" "fmt" "io" "net/http" "time" ) const cloudflareBaseURL = "https://api.cloudflare.com/client/v4" // CloudflareClient es el cliente HTTP para la API de Cloudflare. // Soporta dos modos de autenticación: // - API Token (recomendado): Authorization: Bearer // - Global API Key (legacy): X-Auth-Email + X-Auth-Key type CloudflareClient struct { token string // API Token o Global API Key email string // Solo para Global API Key globalKey bool // true = usar X-Auth-Email + X-Auth-Key accountID string httpClient *http.Client } // NewCloudflareClient crea un cliente con API Token (Bearer). func NewCloudflareClient(token, accountID string) *CloudflareClient { return &CloudflareClient{ token: token, accountID: accountID, httpClient: &http.Client{ Timeout: 20 * time.Second, }, } } // NewCloudflareClientGlobalKey crea un cliente con Global API Key (X-Auth-Email + X-Auth-Key). func NewCloudflareClientGlobalKey(email, apiKey, accountID string) *CloudflareClient { return &CloudflareClient{ token: apiKey, email: email, globalKey: true, accountID: accountID, httpClient: &http.Client{ Timeout: 20 * time.Second, }, } } // IsGlobalKey indica si el cliente usa Global API Key en lugar de API Token. func (c *CloudflareClient) IsGlobalKey() bool { return c.globalKey } // addAuth aplica los headers de autenticación correctos según el tipo de credencial. func (c *CloudflareClient) addAuth(req *http.Request) { if c.globalKey { req.Header.Set("X-Auth-Email", c.email) req.Header.Set("X-Auth-Key", c.token) } else { req.Header.Set("Authorization", "Bearer "+c.token) } req.Header.Set("Content-Type", "application/json") } // get realiza una petición GET autenticada y decodifica el result. func (c *CloudflareClient) get(path string, dest interface{}) error { req, err := http.NewRequest(http.MethodGet, cloudflareBaseURL+path, nil) if err != nil { return fmt.Errorf("cloudflare: crear request: %w", err) } c.addAuth(req) resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("cloudflare: ejecutar request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("cloudflare: leer body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("cloudflare: status %d – %s", resp.StatusCode, string(body)) } // Cloudflare siempre envuelve en {"result":..., "success":true} var wrapper struct { Success bool `json:"success"` Errors []CFError `json:"errors"` Result json.RawMessage `json:"result"` } if err := json.Unmarshal(body, &wrapper); err != nil { return fmt.Errorf("cloudflare: decodificar envelope: %w", err) } if !wrapper.Success { if len(wrapper.Errors) > 0 { return fmt.Errorf("cloudflare API error %d: %s", wrapper.Errors[0].Code, wrapper.Errors[0].Message) } return fmt.Errorf("cloudflare: respuesta no exitosa") } if dest != nil && wrapper.Result != nil { if err := json.Unmarshal(wrapper.Result, dest); err != nil { return fmt.Errorf("cloudflare: decodificar result: %w", err) } } return nil } // ─── Tipos de error ────────────────────────────────────────────────────────── type CFError struct { Code int `json:"code"` Message string `json:"message"` DocumentationURL string `json:"documentation_url,omitempty"` } // ─── Tipos de respuesta ────────────────────────────────────────────────────── // CFUser información del usuario autenticado. type CFUser struct { ID string `json:"id"` Email string `json:"email"` FirstName string `json:"first_name"` LastName string `json:"last_name"` 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"` 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. type CFPlan struct { ID string `json:"id"` 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"` 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 un certificate pack de una zona. type CFSSLStatus struct { ID string `json:"id"` Type string `json:"type"` // universal, advanced, custom, sni_custom Hosts []string `json:"hosts"` Status string `json:"status"` // active, pending_validation, deleted ValidationMethod string `json:"validation_method,omitempty"` ValidityDays int `json:"validity_days,omitempty"` CertificateAuthority string `json:"certificate_authority,omitempty"` Wildcard bool `json:"wildcard,omitempty"` } // CFUniversalSSL representa la configuración de Universal SSL de una zona. type CFUniversalSSL struct { Enabled bool `json:"enabled"` } // CFFirewallRule representa una regla de firewall (Access Rules). type CFFirewallRule struct { ID string `json:"id"` Notes string `json:"notes"` AllowedModes []string `json:"allowed_modes"` Mode string `json:"mode"` Configuration CFFirewallCfg `json:"configuration"` Scope CFScope `json:"scope"` CreatedOn string `json:"created_on"` ModifiedOn string `json:"modified_on"` } // CFFirewallCfg configuración del target en la regla. type CFFirewallCfg struct { Target string `json:"target"` Value string `json:"value"` } // CFScope scope de la regla (zone o account). type CFScope struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` } // ─── Métodos de la API ─────────────────────────────────────────────────────── // GetUser obtiene la información del usuario autenticado. func (c *CloudflareClient) GetUser() (*CFUser, error) { var user CFUser if err := c.get("/user", &user); err != nil { return nil, err } return &user, nil } // CFTokenVerify resultado de la verificación del token. type CFTokenVerify struct { ID string `json:"id"` Status string `json:"status"` NotBefore string `json:"not_before"` ExpiresOn string `json:"expires_on"` } // CFTokenPolicy representa una política de permisos del token. type CFTokenPolicy struct { ID string `json:"id"` Effect string `json:"effect"` Resources map[string]string `json:"resources"` PermGroups []CFTokenPermGroup `json:"permission_groups"` } // CFTokenPermGroup un grupo de permisos. type CFTokenPermGroup struct { ID string `json:"id"` Name string `json:"name"` } // CFTokenDetail detalle completo de un token incluyendo sus políticas. type CFTokenDetail struct { ID string `json:"id"` Name string `json:"name"` Status string `json:"status"` Policies []CFTokenPolicy `json:"policies"` } // VerifyToken verifica la validez del API token usando /user/tokens/verify. func (c *CloudflareClient) VerifyToken() (*CFTokenVerify, error) { var result CFTokenVerify if err := c.get("/user/tokens/verify", &result); err != nil { return nil, err } return &result, nil } // GetTokenDetail obtiene el detalle completo de un token (permisos y recursos). func (c *CloudflareClient) GetTokenDetail(tokenID string) (*CFTokenDetail, error) { var detail CFTokenDetail if err := c.get("/user/tokens/"+tokenID, &detail); err != nil { return nil, err } return &detail, 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"` } // 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) } c.addAuth(req) 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. // Incluye ?status=all para ver packs en cualquier estado (requerido en planes Free). func (c *CloudflareClient) GetSSLCertificates(zoneID string) ([]CFSSLStatus, error) { var certs []CFSSLStatus path := fmt.Sprintf("/zones/%s/ssl/certificate_packs?status=all", zoneID) if err := c.get(path, &certs); err != nil { return nil, err } return certs, nil } // GetUniversalSSLSettings devuelve si el Universal SSL está activo en la zona. // Funciona en todos los planes. Útil como fallback cuando certificate_packs no está disponible. func (c *CloudflareClient) GetUniversalSSLSettings(zoneID string) (*CFUniversalSSL, error) { var result CFUniversalSSL path := fmt.Sprintf("/zones/%s/ssl/universal/settings", zoneID) if err := c.get(path, &result); err != nil { return nil, err } return &result, nil } // GetFirewallRules devuelve las reglas de acceso IP de una zona. func (c *CloudflareClient) GetFirewallRules(zoneID string) ([]CFFirewallRule, error) { var rules []CFFirewallRule path := fmt.Sprintf("/zones/%s/firewall/access_rules/rules?per_page=50&page=1", zoneID) if err := c.get(path, &rules); err != nil { return nil, err } return rules, nil } // ─── Helpers de escritura ──────────────────────────────────────────────────── // doRequest realiza una petición con body JSON y decodifica la respuesta. func (c *CloudflareClient) doRequest(method, path string, payload interface{}, dest interface{}) error { var bodyReader io.Reader if payload != nil { data, err := json.Marshal(payload) if err != nil { return fmt.Errorf("cloudflare: serializar payload: %w", err) } bodyReader = bytes.NewReader(data) } req, err := http.NewRequest(method, cloudflareBaseURL+path, bodyReader) if err != nil { return fmt.Errorf("cloudflare: crear request: %w", err) } c.addAuth(req) resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("cloudflare: ejecutar request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("cloudflare: leer body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("cloudflare: status %d – %s", resp.StatusCode, string(body)) } // Para DELETE la respuesta puede ser vacía o tener solo {"success":true,"result":{"id":"..."}} if len(body) == 0 { return nil } var wrapper struct { Success bool `json:"success"` Errors []CFError `json:"errors"` Result json.RawMessage `json:"result"` } if err := json.Unmarshal(body, &wrapper); err != nil { return fmt.Errorf("cloudflare: decodificar envelope: %w", err) } if !wrapper.Success { if len(wrapper.Errors) > 0 { return fmt.Errorf("cloudflare API error %d: %s", wrapper.Errors[0].Code, wrapper.Errors[0].Message) } return fmt.Errorf("cloudflare: respuesta no exitosa") } if dest != nil && wrapper.Result != nil { if err := json.Unmarshal(wrapper.Result, dest); err != nil { return fmt.Errorf("cloudflare: decodificar result: %w", err) } } return nil } // ─── Tipos de entrada para DNS ─────────────────────────────────────────────── // 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 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 ──────────────────────────────────────────────────────────────── // CreateDNSRecord crea un nuevo registro DNS en la zona indicada. func (c *CloudflareClient) CreateDNSRecord(zoneID string, input CFDNSRecordInput) (*CFDNSRecord, error) { var record CFDNSRecord path := fmt.Sprintf("/zones/%s/dns_records", zoneID) if err := c.doRequest(http.MethodPost, path, input, &record); err != nil { return nil, err } return &record, nil } // UpdateDNSRecord actualiza (PUT completo) un registro DNS existente. func (c *CloudflareClient) UpdateDNSRecord(zoneID, recordID string, input CFDNSRecordInput) (*CFDNSRecord, error) { var record CFDNSRecord path := fmt.Sprintf("/zones/%s/dns_records/%s", zoneID, recordID) if err := c.doRequest(http.MethodPut, path, input, &record); err != nil { return nil, err } return &record, nil } // PatchDNSRecord actualiza parcialmente (PATCH) un registro DNS existente. func (c *CloudflareClient) PatchDNSRecord(zoneID, recordID string, input CFDNSRecordInput) (*CFDNSRecord, error) { var record CFDNSRecord path := fmt.Sprintf("/zones/%s/dns_records/%s", zoneID, recordID) if err := c.doRequest(http.MethodPatch, path, input, &record); err != nil { return nil, err } return &record, nil } // DeleteDNSRecord elimina un registro DNS por su ID. func (c *CloudflareClient) DeleteDNSRecord(zoneID, recordID string) error { path := fmt.Sprintf("/zones/%s/dns_records/%s", zoneID, recordID) return c.doRequest(http.MethodDelete, path, nil, nil) }