Skip to content

Bóveda Fiscal

La Bóveda Fiscal es el subsistema de GM Hades que proporciona acceso de solo lectura a dos fuentes de datos: una base de datos MariaDB alojada en un servidor on-premise accesible únicamente vía SSH, y un servidor SFTP que almacena los CFDIs XML (comprobantes fiscales digitales) de los clientes. Ambos servicios son de solo lectura por diseño.

Configuración de conexiones (connections.json)

Los parámetros de conexión por RFC se definen en internal/boveda_fiscal/config/connections.json, que se embebe en la imagen Docker en /config/boveda_fiscal/. Las contraseñas nunca se almacenan en el archivo; los campos password_env referencian el nombre de la variable de entorno que contiene la credencial real:

{
"rfc": "MTC0109038R4",
"ssh": {
"host": "ssh.servidor-erp.com",
"port": 22,
"user": "gmtransport",
"password_env": "BOVEDA_FISCAL_SSH_PASSWORD"
},
"mariadb": {
"host": "localhost",
"port": 3306,
"user": "boveda_user",
"password_env": "BOVEDA_FISCAL_DB_PASSWORD",
"database": "boveda_fiscal",
"params": "charset=utf8mb4&parseTime=True&loc=Local&timeout=30s"
},
"sftp": {
"host": "sftp.servidor-erp.com",
"port": 22,
"user": "sftp_user",
"password_env": "BOVEDA_FISCAL_SFTP_PASSWORD",
"base_path": "/facturas",
"cfdi_subdirs": ["emitidas", "recibidas"]
}
}

En tiempo de ejecución, BovedaConnectionsLoader.GetConnection(rfc) resuelve los campos password_env leyendo las variables de entorno y devuelve un struct ResolvedBovedaConnection con las credenciales concretas. El RFC se normaliza a mayúsculas antes de la búsqueda.

Bóveda Fiscal DB: MariaDB vía túnel SSH

Mecanismo del túnel SSH

Cada operación de base de datos abre y cierra su propio túnel SSH. No existe un túnel persistente en background. El flujo por operación es:

sequenceDiagram
    participant R as BovedaMariaDBRepository
    participant SSH as SSH Server
    participant MY as MariaDB

    R->>SSH: gossh.Dial(sshAddr, sshClientConfig)
    R->>R: mysql.RegisterDialContext(uniqueName, sshClient.Dial)
    R->>MY: sql.Open("mysql", dsn con dialer custom)
    R->>MY: db.PingContext (verifica túnel)
    R->>MY: db.QueryContext(ctx, sql, params...)
    R->>R: Scan filas con buffer reutilizable
    R->>MY: db.Close()
    R->>SSH: sshClient.Close()

El mysql.RegisterDialContext registra un dialer con un nombre único por operación que enruta las conexiones MySQL a través del canal SSH. Esto es transparente para el driver database/sql: desde su perspectiva, está abriendo una conexión TCP normal.

El pool de conexiones de cada sesión tunelizada se configura con MaxOpenConns(1) y MaxIdleConns(1), ya que el túnel se abre y cierra por operación.

Aplicación del solo lectura

La función IsSelectQuery valida que la sentencia sea SELECT, SHOW, DESCRIBE o EXPLAIN antes de abrir el túnel SSH. Cualquier otra sentencia devuelve codes.InvalidArgument inmediatamente. Esta validación existe en el handler antes de invocar el service layer.

Streaming de resultados

StreamQuery divide el resultado en chunks usando un callback que acumula filas y llama a stream.Send() cuando el batch alcanza el chunkSize configurado (default 100, máximo 1 000 filas por chunk). El buffer de scan []interface{} se pre-asigna y se reutiliza a través de todas las filas para evitar allocaciones en el loop:

scanBuffer := make([]interface{}, len(columns))
valuePointers := make([]interface{}, len(columns))
for i := range scanBuffer {
valuePointers[i] = &scanBuffer[i]
}
for rows.Next() {
rows.Scan(valuePointers...)
// ... construir row y acumular
}

Bóveda Fiscal SFTP: CFDIs XML

Conexión SFTP

Cada operación SFTP abre una conexión SSH con golang.org/x/crypto/ssh y crea un cliente sftp.Client sobre ella con github.com/pkg/sftp. Ambos se cierran al finalizar la operación. No hay conexiones persistentes.

Resolución de UUID a archivo

FindByUUID y StreamDownloadByUUID localizan un CFDI sin que el caller conozca la estructura de directorios. La estrategia opera en dos pasos:

  1. Coincidencia por nombre de archivo (estrategia primaria): Recorre los search_paths buscando archivos cuyo nombre coincida con el patrón ^[0-9a-f]{8}-...-[0-9a-f]{12}\.xml$ (insensible a mayúsculas).
  2. Escaneo de contenido XML (fallback opcional): Si search_in_content=true, abre y parsea los archivos XML para encontrar el UUID en el contenido. Solo se activa explícitamente.

Optimización de búsqueda por rango de fecha

Cuando SftpBatchZipRequest incluye date_from y date_to (formato YYYY-MM) y la conexión tiene sftp_base_path y sftp_cfdi_subdirs configurados, la búsqueda evita el recorrido completo del árbol de directorios. En su lugar, construye rutas exactas:

sftp_base_path / subdir / YYYY / MM /

Para cada UUID y cada mes en el rango de fechas, ejecuta un único sftpClient.Stat(ruta/UUID.xml). Esta operación es O(1) por UUID por mes, en contraste con el recorrido DFS cuya complejidad es O(tamaño del árbol). Para lotes de cientos de UUIDs en rangos de varios meses, esta optimización reduce el tiempo de resolución en uno o dos órdenes de magnitud.

Arquitectura del ZIP en streaming

DownloadBatchAsZip transmite el archivo ZIP usando io.Pipe sin cargar nunca el archivo completo en memoria:

pipeReader, pipeWriter := io.Pipe()
// Goroutine productora: escribe en el pipe
go func() {
defer pipeWriter.Close()
zipWriter := zip.NewWriter(pipeWriter)
for _, path := range resolvedPaths {
header, _ := zip.FileInfoHeader(fileInfo)
w, _ := zipWriter.CreateHeader(header)
sftpFile, _ := sftpClient.Open(path)
io.Copy(w, sftpFile)
sftpFile.Close()
}
zipWriter.Close()
}()
// Goroutine consumidora (main): lee del pipe y emite chunks DATA
buf := make([]byte, 64*1024)
for {
n, err := pipeReader.Read(buf)
if n > 0 {
stream.Send(&pb.SftpBatchZipResponse{
ChunkType: pb.DATA,
Data: buf[:n],
})
}
if err == io.EOF { break }
}

El primer chunk DATA incluye zip_file_name, files_found, files_not_found y not_found_uuids. El último chunk DATA incluye total_size_bytes.

Ejemplo de consumo del stream de ZIP en Go

stream, err := bovedaClient.DownloadBatchAsZip(ctx, &pb.SftpBatchZipRequest{
Rfc: "MTC0109038R4",
Uuids: []string{"8FB0384B-1234-...", "0005537A-5678-..."},
DateFrom: "2025-01",
DateTo: "2025-03",
})
f, _ := os.Create("facturas_batch.zip")
defer f.Close()
for {
chunk, err := stream.Recv()
if err == io.EOF { break }
if err != nil { log.Fatal(err) }
switch chunk.ChunkType {
case pb.PROGRESS:
fmt.Printf("[%.0f%%] %s\n", chunk.ProgressPercent, chunk.ProgressStatus)
case pb.DATA:
f.Write(chunk.Data)
if chunk.IsLast {
fmt.Printf("ZIP completado: %d bytes, %d encontrados, %d no encontrados\n",
chunk.TotalSizeBytes, chunk.FilesFound, chunk.FilesNotFound)
}
}
}

Resumen

  • Los parámetros de conexión se definen en connections.json con referencias a variables de entorno para las contraseñas. Nunca se almacenan credenciales en el archivo.
  • Cada operación de Bóveda Fiscal DB abre y cierra su propio túnel SSH. No hay túneles persistentes ni background goroutines.
  • Solo SELECT, SHOW, DESCRIBE y EXPLAIN son aceptados. La validación ocurre en el handler antes de abrir el túnel.
  • La búsqueda por UUID en SFTP opera en O(1) por UUID por mes cuando se proporciona un rango de fechas y la conexión tiene sftp_base_path configurado.
  • DownloadBatchAsZip transmite el ZIP vía io.Pipe sin carga en memoria. Emite chunks PROGRESS durante la resolución y chunks DATA durante la transmisión del archivo.