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:
- Coincidencia por nombre de archivo (estrategia primaria): Recorre los
search_pathsbuscando archivos cuyo nombre coincida con el patrón^[0-9a-f]{8}-...-[0-9a-f]{12}\.xml$(insensible a mayúsculas). - 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 pipego 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 DATAbuf := 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.jsoncon 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,DESCRIBEyEXPLAINson 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_pathconfigurado. DownloadBatchAsZiptransmite el ZIP víaio.Pipesin carga en memoria. Emite chunks PROGRESS durante la resolución y chunks DATA durante la transmisión del archivo.