From 40131e7266f3f01b91213193fea395dd8e7807fb Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Tue, 12 May 2026 10:29:00 -0500 Subject: [PATCH] up --- pkg/models/cloudflare_config.go | 20 ++- pkg/services/cloudflare_service.go | 47 ++++-- rest/controllers/cloudflare_controller.go | 172 +++++++++++++++++++--- 3 files changed, 204 insertions(+), 35 deletions(-) diff --git a/pkg/models/cloudflare_config.go b/pkg/models/cloudflare_config.go index 0cc1db3..8ac85fe 100644 --- a/pkg/models/cloudflare_config.go +++ b/pkg/models/cloudflare_config.go @@ -5,16 +5,24 @@ import ( "gorm.io/gorm" ) -// CloudflareConfig almacena el API Token de Cloudflare. +// CloudflareConfig almacena las credenciales de Cloudflare. // Solo un registro puede estar activo a la vez. +// +// AuthType puede ser: +// - "token" → API Token (recomendado, 40 chars, Bearer auth) +// - "global_key" → Global API Key (legacy, X-Auth-Email + X-Auth-Key) type CloudflareConfig struct { gorm.Model - // API Token (recomendado) – se usa como Bearer token - APIToken string `json:"api_token" gorm:"column:api_token;type:text;not null"` + // Tipo de autenticación: "token" (defecto) o "global_key" + AuthType string `json:"auth_type" gorm:"column:auth_type;type:varchar(20);default:'token'"` + // API Token (AuthType=token) o Global API Key (AuthType=global_key) + APIToken string `json:"api_token" gorm:"column:api_token;type:text;not null"` + // Email de la cuenta (requerido solo para AuthType=global_key) + Email string `json:"email" gorm:"column:email;type:text"` // Account ID principal (opcional, para endpoints de cuenta) AccountID string `json:"account_id" gorm:"column:account_id;type:text"` - Activo bool `json:"activo" gorm:"column:activo;default:true"` - Nota string `json:"nota" gorm:"column:nota;type:text"` + Activo bool `json:"activo" gorm:"column:activo;default:true"` + Nota string `json:"nota" gorm:"column:nota;type:text"` } func (CloudflareConfig) TableName() string { return "cloudflare_config" } @@ -35,7 +43,9 @@ func SaveCloudflareConfig(s CloudflareConfig) error { s.Activo = true if s.ID > 0 { return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{ + "auth_type": s.AuthType, "api_token": s.APIToken, + "email": s.Email, "account_id": s.AccountID, "nota": s.Nota, "activo": true, diff --git a/pkg/services/cloudflare_service.go b/pkg/services/cloudflare_service.go index 4dccb9b..ac07768 100644 --- a/pkg/services/cloudflare_service.go +++ b/pkg/services/cloudflare_service.go @@ -12,13 +12,18 @@ import ( 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 + 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 el API Token. +// NewCloudflareClient crea un cliente con API Token (Bearer). func NewCloudflareClient(token, accountID string) *CloudflareClient { return &CloudflareClient{ token: token, @@ -29,14 +34,40 @@ func NewCloudflareClient(token, accountID string) *CloudflareClient { } } -// get realiza una petición GET autenticada y devuelve el body. +// 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) } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Content-Type", "application/json") + c.addAuth(req) resp, err := c.httpClient.Do(req) if err != nil { @@ -258,8 +289,7 @@ func (c *CloudflareClient) getWithInfo(path string, dest interface{}) (cfResultI if err != nil { return cfResultInfo{}, fmt.Errorf("cloudflare: crear request: %w", err) } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Content-Type", "application/json") + c.addAuth(req) resp, err := c.httpClient.Do(req) if err != nil { @@ -381,8 +411,7 @@ func (c *CloudflareClient) doRequest(method, path string, payload interface{}, d 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") + c.addAuth(req) resp, err := c.httpClient.Do(req) if err != nil { diff --git a/rest/controllers/cloudflare_controller.go b/rest/controllers/cloudflare_controller.go index 2eb172b..5813d37 100644 --- a/rest/controllers/cloudflare_controller.go +++ b/rest/controllers/cloudflare_controller.go @@ -1,6 +1,7 @@ package controllers import ( + "fmt" "strings" "unicode" @@ -9,12 +10,30 @@ import ( "github.com/sujit-baniya/fiber-boilerplate/pkg/services" ) -// sanitizeCFToken elimina espacios, saltos de línea, BOM y cualquier caracter -// no imprimible que pueda romper la autenticación Bearer de Cloudflare. +// sanitizeCFToken limpia el token antes de usarlo como Bearer en Cloudflare. +// Cubre los casos más comunes de pegado incorrecto desde el dashboard: +// - BOM UTF-8 +// - Prefijo "Bearer " copiado accidentalmente +// - Comillas simples/dobles alrededor del token +// - Espacios y caracteres de control (tabulaciones, saltos de línea) +// - Caracteres fuera del rango ASCII imprimible (U+0021–U+007E) func sanitizeCFToken(s string) string { - // Eliminar BOM UTF-8 si existe + // 1. BOM UTF-8 s = strings.TrimPrefix(s, "\xef\xbb\xbf") - // Filtrar solo rómanos imprimibles (printable ASCII, excluye control chars) + // 2. Espacios al inicio/fin (cubre paste con espacio visible) + s = strings.TrimSpace(s) + // 3. Prefijo "Bearer " copiado accidentalmente (case-insensitive) + if after, ok := strings.CutPrefix(strings.ToLower(s), "bearer "); ok { + s = s[len(s)-len(after):] // preservar casing original del token + } + s = strings.TrimSpace(s) + // 4. Comillas envolventes (simples o dobles) + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + s = s[1 : len(s)-1] + } + } + // 5. Solo ASCII imprimible (U+0021–U+007E), descarta control chars y unicode var b strings.Builder for _, r := range s { if r > 32 && r < 127 && unicode.IsPrint(r) { @@ -40,13 +59,15 @@ func CloudflareConfigPage(c *fiber.Ctx) error { return nil } -// SaveCloudflareConfig guarda o actualiza el API token de Cloudflare. +// SaveCloudflareConfig guarda o actualiza las credenciales de Cloudflare. func SaveCloudflareConfig(c *fiber.Ctx) error { type body struct { - ID uint `json:"id" form:"id"` + ID uint `json:"id" form:"id"` + AuthType string `json:"auth_type" form:"auth_type"` // "token" o "global_key" APIToken string `json:"api_token" form:"api_token"` + Email string `json:"email" form:"email"` AccountID string `json:"account_id" form:"account_id"` - Nota string `json:"nota" form:"nota"` + Nota string `json:"nota" form:"nota"` } var b body if err := c.BodyParser(&b); err != nil { @@ -55,9 +76,17 @@ func SaveCloudflareConfig(c *fiber.Ctx) error { if b.APIToken == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "api_token requerido"}) } + if b.AuthType == "" { + b.AuthType = "token" + } + if b.AuthType == "global_key" && b.Email == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email requerido para auth_type=global_key"}) + } cfg := models.CloudflareConfig{ + AuthType: b.AuthType, APIToken: sanitizeCFToken(b.APIToken), + Email: strings.TrimSpace(b.Email), AccountID: sanitizeCFToken(b.AccountID), Nota: b.Nota, } @@ -81,6 +110,7 @@ func CleanCloudflareToken(c *fiber.Ctx) error { before := len([]rune(cfg.APIToken)) cfg.APIToken = sanitizeCFToken(cfg.APIToken) cfg.AccountID = sanitizeCFToken(cfg.AccountID) + cfg.Email = strings.TrimSpace(cfg.Email) after := len([]rune(cfg.APIToken)) if err := models.SaveCloudflareConfig(*cfg); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) @@ -98,10 +128,17 @@ func cloudflareClient() (*services.CloudflareClient, error) { if err != nil { return nil, err } + if cfg.AuthType == "global_key" { + return services.NewCloudflareClientGlobalKey( + strings.TrimSpace(cfg.Email), + sanitizeCFToken(cfg.APIToken), + sanitizeCFToken(cfg.AccountID), + ), nil + } return services.NewCloudflareClient(sanitizeCFToken(cfg.APIToken), sanitizeCFToken(cfg.AccountID)), nil } -// VerifyCloudflareToken verifica el token y devuelve sus permisos reales. +// VerifyCloudflareToken verifica las credenciales y devuelve sus permisos reales. func VerifyCloudflareToken(c *fiber.Ctx) error { cfg, err := models.GetCloudflareConfig() if err != nil { @@ -110,31 +147,124 @@ func VerifyCloudflareToken(c *fiber.Ctx) error { "error": "No hay configuración activa de Cloudflare", }) } + raw := cfg.APIToken token := sanitizeCFToken(raw) if token == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"ok": false, "error": "Token vacío"}) } - dirty := len([]rune(raw)) != len([]rune(token)) + dirty := raw != token runes := []rune(token) - preview := "" - if len(runes) >= 8 { - preview = string(runes[:4]) + "…" + string(runes[len(runes)-4:]) + preview := token + if len(runes) > 14 { + preview = string(runes[:6]) + "…" + string(runes[len(runes)-6:]) } - client := services.NewCloudflareClient(token, sanitizeCFToken(cfg.AccountID)) + isGlobal := cfg.AuthType == "global_key" - // 1. Verificar token + // Diagnóstico de formato según tipo + var tokenDiag fiber.Map + if isGlobal { + hintFormat := "" + if len(token) != 37 { + hintFormat = fmt.Sprintf( + "Longitud %d ≠ 37. La Global API Key tiene exactamente 37 caracteres hexadecimales.", + len(token), + ) + } + tokenDiag = fiber.Map{ + "auth_type": "global_key", + "length": len(token), + "expected_length": "37 caracteres (Global API Key)", + "preview": preview, + "was_sanitized": dirty, + "email": cfg.Email, + "hint_format": hintFormat, + } + } else { + hintFormat := "" + if len(token) != 40 { + hintFormat = fmt.Sprintf( + "Longitud %d ≠ 40. Posible token incompleto, con prefijo extra, o tipo incorrecto (¿estás usando Global API Key en vez de API Token?)", + len(token), + ) + } + tokenDiag = fiber.Map{ + "auth_type": "token", + "length": len(token), + "expected_length": "40 caracteres (API Token estándar)", + "preview": preview, + "was_sanitized": dirty, + "raw_length": len([]rune(raw)), + "hint_format": hintFormat, + } + } + + var client *services.CloudflareClient + if isGlobal { + client = services.NewCloudflareClientGlobalKey( + strings.TrimSpace(cfg.Email), + token, + sanitizeCFToken(cfg.AccountID), + ) + } else { + client = services.NewCloudflareClient(token, sanitizeCFToken(cfg.AccountID)) + } + + // ── Para Global API Key: no existe /user/tokens/verify, verificamos con /user ── + if isGlobal { + user, err := client.GetUser() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "ok": false, + "step": "get_user", + "error": err.Error(), + "token": tokenDiag, + "hint": "Verifica que el email y la Global API Key sean correctos. La Global API Key se encuentra en dash.cloudflare.com → Mi Perfil → API Tokens → Global API Key → View.", + }) + } + + zones, zonesErr := client.GetZones() + zonesOK := zonesErr == nil + zonesErrMsg := "" + if zonesErr != nil { + zonesErrMsg = zonesErr.Error() + } + + dnsOK := false + dnsErrMsg := "" + dnsZoneID := "" + if zonesOK && len(zones) > 0 { + dnsZoneID = zones[0].ID + _, dnsErr := client.GetDNSRecords(dnsZoneID) + dnsOK = dnsErr == nil + if dnsErr != nil { + dnsErrMsg = dnsErr.Error() + } + } + + return c.JSON(fiber.Map{ + "ok": true, + "auth_type": "global_key", + "user_email": user.Email, + "user_id": user.ID, + "token": tokenDiag, + "permissions_test": fiber.Map{ + "zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg}, + "dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID}, + }, + }) + } + + // ── API Token normal ── verify, err := client.VerifyToken() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "ok": false, - "step": "verify_token", - "error": err.Error(), - "hint": "Token inválido. Crea uno nuevo en dash.cloudflare.com → Mi Perfil → API Tokens", - "token_len": len(token), - "token_prev": preview, - "was_dirty": dirty, + "ok": false, + "step": "verify_token", + "error": err.Error(), + "token": tokenDiag, + "hint": "Ve a dash.cloudflare.com → Mi Perfil → API Tokens → crea un token nuevo con permisos Zone:Read y DNS:Read/Write", }) }