up
This commit is contained in:
@@ -5,12 +5,20 @@ import (
|
|||||||
"gorm.io/gorm"
|
"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.
|
// 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 {
|
type CloudflareConfig struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
// API Token (recomendado) – se usa como Bearer token
|
// 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"`
|
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)
|
// Account ID principal (opcional, para endpoints de cuenta)
|
||||||
AccountID string `json:"account_id" gorm:"column:account_id;type:text"`
|
AccountID string `json:"account_id" gorm:"column:account_id;type:text"`
|
||||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||||
@@ -35,7 +43,9 @@ func SaveCloudflareConfig(s CloudflareConfig) error {
|
|||||||
s.Activo = true
|
s.Activo = true
|
||||||
if s.ID > 0 {
|
if s.ID > 0 {
|
||||||
return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{
|
return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{
|
||||||
|
"auth_type": s.AuthType,
|
||||||
"api_token": s.APIToken,
|
"api_token": s.APIToken,
|
||||||
|
"email": s.Email,
|
||||||
"account_id": s.AccountID,
|
"account_id": s.AccountID,
|
||||||
"nota": s.Nota,
|
"nota": s.Nota,
|
||||||
"activo": true,
|
"activo": true,
|
||||||
|
|||||||
@@ -12,13 +12,18 @@ import (
|
|||||||
const cloudflareBaseURL = "https://api.cloudflare.com/client/v4"
|
const cloudflareBaseURL = "https://api.cloudflare.com/client/v4"
|
||||||
|
|
||||||
// CloudflareClient es el cliente HTTP para la API de Cloudflare.
|
// 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 {
|
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
|
accountID string
|
||||||
httpClient *http.Client
|
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 {
|
func NewCloudflareClient(token, accountID string) *CloudflareClient {
|
||||||
return &CloudflareClient{
|
return &CloudflareClient{
|
||||||
token: token,
|
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 {
|
func (c *CloudflareClient) get(path string, dest interface{}) error {
|
||||||
req, err := http.NewRequest(http.MethodGet, cloudflareBaseURL+path, nil)
|
req, err := http.NewRequest(http.MethodGet, cloudflareBaseURL+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cloudflare: crear request: %w", err)
|
return fmt.Errorf("cloudflare: crear request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
c.addAuth(req)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -258,8 +289,7 @@ func (c *CloudflareClient) getWithInfo(path string, dest interface{}) (cfResultI
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return cfResultInfo{}, fmt.Errorf("cloudflare: crear request: %w", err)
|
return cfResultInfo{}, fmt.Errorf("cloudflare: crear request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
c.addAuth(req)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -381,8 +411,7 @@ func (c *CloudflareClient) doRequest(method, path string, payload interface{}, d
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cloudflare: crear request: %w", err)
|
return fmt.Errorf("cloudflare: crear request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
c.addAuth(req)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
@@ -9,12 +10,30 @@ import (
|
|||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sanitizeCFToken elimina espacios, saltos de línea, BOM y cualquier caracter
|
// sanitizeCFToken limpia el token antes de usarlo como Bearer en Cloudflare.
|
||||||
// no imprimible que pueda romper la autenticación Bearer de 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 {
|
func sanitizeCFToken(s string) string {
|
||||||
// Eliminar BOM UTF-8 si existe
|
// 1. BOM UTF-8
|
||||||
s = strings.TrimPrefix(s, "\xef\xbb\xbf")
|
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
|
var b strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
if r > 32 && r < 127 && unicode.IsPrint(r) {
|
if r > 32 && r < 127 && unicode.IsPrint(r) {
|
||||||
@@ -40,11 +59,13 @@ func CloudflareConfigPage(c *fiber.Ctx) error {
|
|||||||
return nil
|
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 {
|
func SaveCloudflareConfig(c *fiber.Ctx) error {
|
||||||
type body struct {
|
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"`
|
APIToken string `json:"api_token" form:"api_token"`
|
||||||
|
Email string `json:"email" form:"email"`
|
||||||
AccountID string `json:"account_id" form:"account_id"`
|
AccountID string `json:"account_id" form:"account_id"`
|
||||||
Nota string `json:"nota" form:"nota"`
|
Nota string `json:"nota" form:"nota"`
|
||||||
}
|
}
|
||||||
@@ -55,9 +76,17 @@ func SaveCloudflareConfig(c *fiber.Ctx) error {
|
|||||||
if b.APIToken == "" {
|
if b.APIToken == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "api_token requerido"})
|
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{
|
cfg := models.CloudflareConfig{
|
||||||
|
AuthType: b.AuthType,
|
||||||
APIToken: sanitizeCFToken(b.APIToken),
|
APIToken: sanitizeCFToken(b.APIToken),
|
||||||
|
Email: strings.TrimSpace(b.Email),
|
||||||
AccountID: sanitizeCFToken(b.AccountID),
|
AccountID: sanitizeCFToken(b.AccountID),
|
||||||
Nota: b.Nota,
|
Nota: b.Nota,
|
||||||
}
|
}
|
||||||
@@ -81,6 +110,7 @@ func CleanCloudflareToken(c *fiber.Ctx) error {
|
|||||||
before := len([]rune(cfg.APIToken))
|
before := len([]rune(cfg.APIToken))
|
||||||
cfg.APIToken = sanitizeCFToken(cfg.APIToken)
|
cfg.APIToken = sanitizeCFToken(cfg.APIToken)
|
||||||
cfg.AccountID = sanitizeCFToken(cfg.AccountID)
|
cfg.AccountID = sanitizeCFToken(cfg.AccountID)
|
||||||
|
cfg.Email = strings.TrimSpace(cfg.Email)
|
||||||
after := len([]rune(cfg.APIToken))
|
after := len([]rune(cfg.APIToken))
|
||||||
if err := models.SaveCloudflareConfig(*cfg); err != nil {
|
if err := models.SaveCloudflareConfig(*cfg); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
@@ -98,10 +128,17 @@ func cloudflareClient() (*services.CloudflareClient, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
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 {
|
func VerifyCloudflareToken(c *fiber.Ctx) error {
|
||||||
cfg, err := models.GetCloudflareConfig()
|
cfg, err := models.GetCloudflareConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,31 +147,124 @@ func VerifyCloudflareToken(c *fiber.Ctx) error {
|
|||||||
"error": "No hay configuración activa de Cloudflare",
|
"error": "No hay configuración activa de Cloudflare",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
raw := cfg.APIToken
|
raw := cfg.APIToken
|
||||||
token := sanitizeCFToken(raw)
|
token := sanitizeCFToken(raw)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"ok": false, "error": "Token vacío"})
|
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)
|
runes := []rune(token)
|
||||||
preview := ""
|
preview := token
|
||||||
if len(runes) >= 8 {
|
if len(runes) > 14 {
|
||||||
preview = string(runes[:4]) + "…" + string(runes[len(runes)-4:])
|
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()
|
verify, err := client.VerifyToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
"step": "verify_token",
|
"step": "verify_token",
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
"hint": "Token inválido. Crea uno nuevo en dash.cloudflare.com → Mi Perfil → API Tokens",
|
"token": tokenDiag,
|
||||||
"token_len": len(token),
|
"hint": "Ve a dash.cloudflare.com → Mi Perfil → API Tokens → crea un token nuevo con permisos Zone:Read y DNS:Read/Write",
|
||||||
"token_prev": preview,
|
|
||||||
"was_dirty": dirty,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user