Files
soft_usite/pkg/services/cloudflare_service.go
Lizandro GuarnizoandClaude Sonnet 5 08265510ea feat(umind): mide el consumo de IA, OCR y transcripción por tenant
Es la base del cobro por uso: hasta ahora no había ninguna medición de
consumo en todo el repo.

- UmindUso registra cada evento facturable con el costo YA calculado al
  precio vigente del plan. Congelarlo evita que subir un precio revalúe
  consumo pasado, que haría indefendible una factura ante un reclamo.
- callAI devuelve los tokens que reportó el proveedor (campo usage, igual
  en todos los OpenAI-compatibles; input+output en Anthropic). Se mide
  cada ronda de tool-calling, no solo la última: todas gastan tokens.
- ExtraerTextoOCR y TranscribirAudioSelfHosted reciben agenteID; 0 = no
  medir, que es lo que pasan los botones "Probar" del panel de staff.
- Aviso al superar el tope del plan, una vez por mes y sin cortar el
  servicio. El flag de "ya avisé" es en memoria a propósito.
- GET /app/umind/uso con filtros de fecha: resumen por tipo + detalle.
- Test del cálculo de costo por tipo, incluida fracción de 1k tokens y
  tenant sin plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:41:23 -05:00

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