Files
soft_usite/rest/controllers/cloudflare_controller.go
T
2026-05-12 11:11:45 -05:00

539 lines
18 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 controllers
import (
"fmt"
"strings"
"unicode"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// 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+0021U+007E)
func sanitizeCFToken(s string) string {
// 1. BOM UTF-8
s = strings.TrimPrefix(s, "\xef\xbb\xbf")
// 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+0021U+007E), descarta control chars y unicode
var b strings.Builder
for _, r := range s {
if r > 32 && r < 127 && unicode.IsPrint(r) {
b.WriteRune(r)
}
}
return b.String()
}
// ─── Configuración ────────────────────────────────────────────────────────────
// CloudflareConfigPage renderiza la vista de gestión de Cloudflare.
func CloudflareConfigPage(c *fiber.Ctx) error {
cfg, _ := models.GetCloudflareConfig()
if err := c.Render("cloudflare", fiber.Map{
"Title": "Cloudflare API",
"Config": cfg,
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
}, "layouts/main"); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return nil
}
// SaveCloudflareConfig guarda o actualiza las credenciales de Cloudflare.
func SaveCloudflareConfig(c *fiber.Ctx) error {
type body struct {
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"`
}
var b body
if err := c.BodyParser(&b); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
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,
}
cfg.ID = b.ID
if err := models.SaveCloudflareConfig(cfg); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "Configuración guardada"})
}
// ─── Helper ───────────────────────────────────────────────────────────────────
// CleanCloudflareToken re-guarda el token activo aplicando saneamiento.
// Útil cuando el token fue guardado antes del fix de caracteres invisibles.
func CleanCloudflareToken(c *fiber.Ctx) error {
cfg, err := models.GetCloudflareConfig()
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Sin configuración activa"})
}
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()})
}
return c.JSON(fiber.Map{
"message": "Token re-guardado limpio",
"chars_before": before,
"chars_after": after,
"chars_removed": before - after,
})
}
func cloudflareClient() (*services.CloudflareClient, error) {
cfg, err := models.GetCloudflareConfig()
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 las credenciales y devuelve sus permisos reales.
func VerifyCloudflareToken(c *fiber.Ctx) error {
cfg, err := models.GetCloudflareConfig()
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"ok": false,
"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 := raw != token
runes := []rune(token)
preview := token
if len(runes) > 14 {
preview = string(runes[:6]) + "…" + string(runes[len(runes)-6:])
}
isGlobal := cfg.AuthType == "global_key"
// 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(),
"token": tokenDiag,
"hint": "Ve a dash.cloudflare.com → Mi Perfil → API Tokens → crea un token nuevo con permisos Zone:Read y DNS:Read/Write",
})
}
// 2. Obtener políticas reales del token
detail, detailErr := client.GetTokenDetail(verify.ID)
detailErrMsg := ""
if detailErr != nil {
detailErrMsg = detailErr.Error()
}
// 3. Probar Zone:Zone:Read
zones, zonesErr := client.GetZones()
zonesOK := zonesErr == nil
zonesErrMsg := ""
if zonesErr != nil {
zonesErrMsg = zonesErr.Error()
}
// 4. Probar Zone:DNS:Read en la primera zona
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()
}
}
hint := "Token válido con todos los permisos necesarios"
if !zonesOK {
hint = "Falta permiso Zone:Zone:Read"
} else if !dnsOK {
hint = "Falta permiso Zone:DNS:Read — o el token tiene scope de zonas específicas y no incluye la zona consultada. En Cloudflare: edita el token → Zone Resources → cambia a 'All zones'."
}
return c.JSON(fiber.Map{
"ok": true,
"token_status": verify.Status,
"token_id": verify.ID,
"token_len": len(token),
"token_prev": preview,
"was_dirty": dirty,
"detail_error": detailErrMsg,
"policies": func() interface{} { if detail != nil { return detail.Policies }; return nil }(),
"permissions_test": fiber.Map{
"zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg},
"dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID},
},
"hint": hint,
})
}
// ─── Endpoints de datos ───────────────────────────────────────────────────────
// GetCloudflareUser devuelve la información del usuario autenticado en Cloudflare.
func GetCloudflareUser(c *fiber.Ctx) error {
client, err := cloudflareClient()
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "No se encontró configuración activa de Cloudflare",
})
}
data, err := client.GetUser()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": data})
}
// GetCloudflareZones lista todas las zonas (dominios) de la cuenta.
func GetCloudflareZones(c *fiber.Ctx) error {
client, err := cloudflareClient()
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "No se encontró configuración activa de Cloudflare",
})
}
data, err := client.GetZones()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": data})
}
// GetCloudflareDNS devuelve los registros DNS de una zona (:zone_id).
func GetCloudflareDNS(c *fiber.Ctx) error {
zoneID := c.Params("zone_id")
if zoneID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id requerido"})
}
client, err := cloudflareClient()
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "No se encontró configuración activa de Cloudflare",
})
}
data, err := client.GetDNSRecords(zoneID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": data, "zone_id": zoneID})
}
// GetCloudflareSSL devuelve los certificados SSL de una zona (:zone_id).
// Intenta primero certificate_packs?status=all; si falla (plan Free o sin ACM)
// devuelve el estado de Universal SSL como fallback.
func GetCloudflareSSL(c *fiber.Ctx) error {
zoneID := c.Params("zone_id")
if zoneID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id requerido"})
}
client, err := cloudflareClient()
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "No se encontró configuración activa de Cloudflare",
})
}
// 1. Intentar certificate_packs?status=all
packs, packsErr := client.GetSSLCertificates(zoneID)
if packsErr == nil {
return c.JSON(fiber.Map{"data": packs, "zone_id": zoneID, "source": "certificate_packs"})
}
// 2. Fallback: Universal SSL settings (disponible en todos los planes)
universal, uErr := client.GetUniversalSSLSettings(zoneID)
if uErr != nil {
// Devolver ambos errores para diagnóstico
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": packsErr.Error(),
"universal_error": uErr.Error(),
"hint": "El endpoint ssl/certificate_packs requiere permiso 'SSL and Certificates:Read'. Si usas API Token, asegúrate de que tenga ese permiso.",
})
}
return c.JSON(fiber.Map{
"data": nil,
"zone_id": zoneID,
"source": "universal_ssl",
"universal_ssl": fiber.Map{
"enabled": universal.Enabled,
"note": "Plan actual no expone certificate_packs. Universal SSL: " + func() string { if universal.Enabled { return "ACTIVO" }; return "INACTIVO" }(),
},
"packs_error": packsErr.Error(),
})
}
// GetCloudflareFirewall devuelve las reglas de firewall de una zona (:zone_id).
func GetCloudflareFirewall(c *fiber.Ctx) error {
zoneID := c.Params("zone_id")
if zoneID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id requerido"})
}
client, err := cloudflareClient()
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "No se encontró configuración activa de Cloudflare",
})
}
data, err := client.GetFirewallRules(zoneID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": data, "zone_id": zoneID})
}
// ─── DNS CRUD ─────────────────────────────────────────────────────────────────
// CreateCloudflareDNS crea un nuevo registro DNS en la zona (:zone_id).
func CreateCloudflareDNS(c *fiber.Ctx) error {
zoneID := c.Params("zone_id")
if zoneID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id requerido"})
}
var input services.CFDNSRecordInput
if err := c.BodyParser(&input); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if input.Type == "" || input.Name == "" || input.Content == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type, name y content son requeridos"})
}
if input.TTL == 0 {
input.TTL = 1 // automático
}
client, err := cloudflareClient()
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "No se encontró configuración activa de Cloudflare",
})
}
record, err := client.CreateDNSRecord(zoneID, input)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"data": record})
}
// UpdateCloudflareDNS actualiza (PUT completo) un registro DNS (:zone_id/:record_id).
func UpdateCloudflareDNS(c *fiber.Ctx) error {
zoneID := c.Params("zone_id")
recordID := c.Params("record_id")
if zoneID == "" || recordID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id y record_id requeridos"})
}
var input services.CFDNSRecordInput
if err := c.BodyParser(&input); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if input.Type == "" || input.Name == "" || input.Content == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type, name y content son requeridos"})
}
if input.TTL == 0 {
input.TTL = 1
}
client, err := cloudflareClient()
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "No se encontró configuración activa de Cloudflare",
})
}
record, err := client.UpdateDNSRecord(zoneID, recordID, input)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": record})
}
// PatchCloudflareDNS actualiza parcialmente un registro DNS (:zone_id/:record_id).
func PatchCloudflareDNS(c *fiber.Ctx) error {
zoneID := c.Params("zone_id")
recordID := c.Params("record_id")
if zoneID == "" || recordID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id y record_id requeridos"})
}
var input services.CFDNSRecordInput
if err := c.BodyParser(&input); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
client, err := cloudflareClient()
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "No se encontró configuración activa de Cloudflare",
})
}
record, err := client.PatchDNSRecord(zoneID, recordID, input)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": record})
}
// DeleteCloudflareDNS elimina un registro DNS (:zone_id/:record_id).
func DeleteCloudflareDNS(c *fiber.Ctx) error {
zoneID := c.Params("zone_id")
recordID := c.Params("record_id")
if zoneID == "" || recordID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id y record_id requeridos"})
}
client, err := cloudflareClient()
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "No se encontró configuración activa de Cloudflare",
})
}
if err := client.DeleteDNSRecord(zoneID, recordID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "Registro DNS eliminado"})
}