Files
soft_usite/pkg/services/cloudflare_service.go
T
2026-05-11 21:47:15 -05:00

345 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
type CloudflareClient struct {
token string
accountID string
httpClient *http.Client
}
// NewCloudflareClient crea un cliente con el API Token.
func NewCloudflareClient(token, accountID string) *CloudflareClient {
return &CloudflareClient{
token: token,
accountID: accountID,
httpClient: &http.Client{
Timeout: 20 * time.Second,
},
}
}
// get realiza una petición GET autenticada y devuelve el body.
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)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
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"`
}
// ─── 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"`
}
// 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"`
}
// CFPlan representa el plan de una zona.
type CFPlan struct {
ID string `json:"id"`
Name string `json:"name"`
}
// 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"`
}
// CFSSLStatus representa el estado SSL de una zona.
type CFSSLStatus struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Hosts []string `json:"hosts"`
PrimaryCert string `json:"primary_certificate"`
ExpiresOn string `json:"expires_on"`
}
// 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"`
}
// 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
}
// 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
}
// 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
}
return records, nil
}
// GetSSLCertificates devuelve los certificate packs de una zona.
func (c *CloudflareClient) GetSSLCertificates(zoneID string) ([]CFSSLStatus, error) {
var certs []CFSSLStatus
path := fmt.Sprintf("/zones/%s/ssl/certificate_packs", zoneID)
if err := c.get(path, &certs); err != nil {
return nil, err
}
return certs, 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)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
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
}
// ─── 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)
}