529 lines
18 KiB
Go
529 lines
18 KiB
Go
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 <token>
|
||
// - 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)
|
||
}
|