SFTP con Go: Conexiones, Rendimiento y Estrategias de Búsqueda para Producción
Backend

SFTP con Go: Conexiones, Rendimiento y Estrategias de Búsqueda para Producción

Aprende a construir clientes SFTP en Go con pooling de conexiones, transferencias seguras, búsqueda eficiente de archivos y patrones production-ready. Incluye comandos CLI y mejores prácticas.

Por Omar Flores
#go #golang #sftp #operaciones-archivos #acceso-remoto #conexiones #rendimiento #cli #mejores-prácticas #seguridad #networking #backend #devops

El problema: SSH está en todos lados, SFTP es mal entendido

Imagina que eres operador manejando 50 servidores. Necesitas recuperar logs de un servidor específico, encontrar archivos que matcheen un patrón y transferirlos de vuelta a tu sistema para análisis.

Podrías SSH al servidor y usar find y scp. Es lento. Es manual. Es propenso a errores.

O podrías escribir un programa en Go que se conecte a 50 servidores simultáneamente, busque archivos que matcheen en paralelo y transferir todos a la vez. Mismos datos. Experiencia vastamente diferente.

SFTP (SSH File Transfer Protocol) es la solución. Está construido sobre SSH. Está instalado en todos lados. Es seguro. Pero la mayoría de desarrolladores lo tratan como una herramienta simple de transferencia de archivos.

Usado correctamente, SFTP es una poderosa abstracción para operaciones remotas de archivos que escala a miles de conexiones concurrentes.

Esta guía te enseña cómo.


Parte 1: Entendiendo SFTP — La base

SFTP no es FTP con SSH. Es un protocolo completamente diferente construido sobre SSH.

¿Por qué SFTP, no SSH SCP?

SSH SCP (Secure Copy):

  • Protocolo simple
  • Una transferencia de archivo a la vez
  • Usa comando SSH externo
  • Bueno para movimiento ad-hoc de archivos
  • Terrible para automatización

SFTP:

  • Protocolo rico con operaciones de archivo
  • Múltiples transferencias concurrentes
  • Soporte de librería built-in (golang.org/x/crypto/ssh)
  • Traversal de directorio
  • Operaciones de archivo stat/chmod/delete
  • Reuso de conexión
  • Bueno para sistemas de producción

Cómo funciona SFTP

Tu Programa

Conexión SSH (encriptada)

Subsistema SFTP en servidor remoto

Operaciones de archivo (leer, escribir, listar, eliminar)

La conexión SSH es el transporte. SFTP es el protocolo ejecutándose encima.


Parte 2: Construyendo tu primera conexión SFTP

El enfoque ingenuo es abrir una conexión, hacer una operación, cerrar. Funciona para tareas únicas. Es terrible para operaciones repetidas.

El enfoque ingenuo (No hagas esto)

import (
	"golang.org/x/crypto/ssh"
	"github.com/pkg/sftp"
)

func downloadFile(host, username, password, remotePath string) error {
	// Dial SSH
	config := &ssh.ClientConfig{
		User: username,
		Auth: []ssh.AuthMethod{
			ssh.Password(password),
		},
		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
	}

	client, err := ssh.Dial("tcp", host+":22", config)
	if err != nil {
		return err
	}
	defer client.Close()

	// Abrir sesión SFTP
	session, err := sftp.NewClient(client)
	if err != nil {
		return err
	}
	defer session.Close()

	// Descargar archivo
	srcFile, err := session.Open(remotePath)
	if err != nil {
		return err
	}
	defer srcFile.Close()

	dstFile, err := os.Create("localfile.txt")
	if err != nil {
		return err
	}
	defer dstFile.Close()

	_, err = io.Copy(dstFile, srcFile)
	return err
}

Funciona. Pero crea una nueva conexión SSH para cada operación. A escala, esto es lento.


Parte 3: Connection Pooling — El enfoque correcto

Los sistemas de producción necesitan reuso de conexión. Abrir una conexión SSH es caro (intercambio de claves, autenticación, handshake). Quieres abrir una vez, usar muchas veces.

Construyendo un pool de conexiones

// sftppool/pool.go
package sftppool

import (
	"fmt"
	"sync"

	"github.com/pkg/sftp"
	"golang.org/x/crypto/ssh"
)

// Connection representa una conexión SFTP reutilizable
type Connection struct {
	ssh    *ssh.Client
	sftp   *sftp.Client
	closed bool
}

// ConnectionPool gestiona múltiples conexiones SFTP
type ConnectionPool struct {
	host     string
	config   *ssh.ClientConfig
	mu       sync.RWMutex
	conns    []*Connection
	maxConns int
	inUse    int
}

// NewPool crea un nuevo pool de conexiones
func NewPool(host string, config *ssh.ClientConfig, maxConns int) *ConnectionPool {
	return &ConnectionPool{
		host:     host,
		config:   config,
		conns:    make([]*Connection, 0, maxConns),
		maxConns: maxConns,
	}
}

// Get obtiene o crea una conexión del pool
func (p *ConnectionPool) Get() (*Connection, error) {
	p.mu.Lock()
	defer p.mu.Unlock()

	// Intenta reusar conexión existente
	for i, conn := range p.conns {
		if !conn.closed {
			p.conns = append(p.conns[:i], p.conns[i+1:]...)
			p.inUse++
			return conn, nil
		}
	}

	// Crea nueva conexión si bajo límite
	if p.inUse < p.maxConns {
		sshClient, err := ssh.Dial("tcp", p.host+":22", p.config)
		if err != nil {
			return nil, fmt.Errorf("ssh dial: %w", err)
		}

		sftpClient, err := sftp.NewClient(sshClient)
		if err != nil {
			sshClient.Close()
			return nil, fmt.Errorf("sftp new client: %w", err)
		}

		p.inUse++
		return &Connection{
			ssh:  sshClient,
			sftp: sftpClient,
		}, nil
	}

	return nil, fmt.Errorf("pool agotado")
}

// Return retorna una conexión al pool
func (p *ConnectionPool) Return(conn *Connection) {
	p.mu.Lock()
	defer p.mu.Unlock()

	if conn.closed {
		p.inUse--
		return
	}

	p.conns = append(p.conns, conn)
	p.inUse--
}

// Close cierra todas las conexiones
func (p *ConnectionPool) Close() error {
	p.mu.Lock()
	defer p.mu.Unlock()

	for _, conn := range p.conns {
		conn.Close()
	}
	p.conns = nil
	return nil
}

// Close cierra una sola conexión
func (c *Connection) Close() error {
	c.closed = true
	c.sftp.Close()
	return c.ssh.Close()
}

Ahora las operaciones reusan conexiones:

// Uso
pool := NewPool("example.com", config, 10) // Max 10 conexiones concurrentes
defer pool.Close()

// Obtén conexión del pool
conn, err := pool.Get()
if err != nil {
	panic(err)
}
defer pool.Return(conn)

// Usa conexión
file, err := conn.sftp.Open("/path/to/file.txt")
// ... haz operaciones ...

Parte 4: Estrategias de búsqueda de archivos — Rápido vs profundo

Buscar 100,000 archivos en un servidor remoto es diferente de buscar localmente.

Estrategia 1: Búsqueda solo por nombre (La más rápida)

Cuando solo necesitas matchear nombres de archivo, lista directorio y filtra:

// SearchByName retorna archivos que matchean un patrón de nombre
func SearchByName(client *sftp.Client, dir, pattern string) ([]string, error) {
	var results []string

	// Camina árbol de directorio
	err := client.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		// Matchea nombre de archivo contra patrón
		if matched, _ := filepath.Match(pattern, filepath.Base(path)); matched {
			results = append(results, path)
		}

		return nil
	})

	return results, err
}

// Uso
files, err := SearchByName(sftpClient, "/var/log", "*.log")

Esto es O(n) donde n = número de archivos en el árbol. Rápido porque solo hace llamadas stat.

Estrategia 2: Búsqueda paralela en directorios

Cuando buscas múltiples directorios top-level, busca en paralelo:

// ParallelSearch busca múltiples directorios concurrentemente
func ParallelSearch(client *sftp.Client, dirs []string, pattern string, workers int) ([]string, error) {
	// Crea cola de trabajo
	workChan := make(chan string, 100)
	resultChan := make(chan string, 1000)
	errChan := make(chan error, workers)

	// Inicia workers
	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for dir := range workChan {
				err := searchDir(client, dir, pattern, resultChan)
				if err != nil {
					errChan <- err
				}
			}
		}()
	}

	// Envía trabajo
	go func() {
		for _, dir := range dirs {
			workChan <- dir
		}
		close(workChan)
	}()

	// Recolecta resultados
	var results []string
	go func() {
		wg.Wait()
		close(resultChan)
	}()

	for result := range resultChan {
		results = append(results, result)
	}

	return results, nil
}

func searchDir(client *sftp.Client, dir, pattern string, results chan<- string) error {
	return client.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return nil // Salta errores, continúa buscando
		}

		if matched, _ := filepath.Match(pattern, filepath.Base(path)); matched {
			results <- path
		}

		return nil
	})
}

// Uso
files, err := ParallelSearch(sftpClient, []string{"/var/log", "/home", "/opt"}, "*.txt", 4)

Esto busca 4 directorios simultáneamente. 4x más rápido que secuencial.

Estrategia 3: Búsqueda de contenido (La más lenta, la más poderosa)

Cuando necesitas buscar contenidos de archivos:

// SearchContents busca contenidos de archivos para un patrón
func SearchContents(client *sftp.Client, dir, filePattern, contentPattern string) ([]string, error) {
	var results []string
	re := regexp.MustCompile(contentPattern)

	return results, client.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return nil
		}

		// Salta directorios y nombres de archivo que no matchean
		if info.IsDir() {
			return nil
		}

		if matched, _ := filepath.Match(filePattern, filepath.Base(path)); !matched {
			return nil
		}

		// Abre y busca archivo
		file, err := client.Open(path)
		if err != nil {
			return nil
		}
		defer file.Close()

		scanner := bufio.NewScanner(file)
		for scanner.Scan() {
			if re.MatchString(scanner.Text()) {
				results = append(results, path)
				break // Encontrado match, muévete al siguiente archivo
			}
		}

		return scanner.Err()
	})
}

// Uso
// Encuentra archivos en *.log que contienen mensajes de error
files, err := SearchContents(sftpClient, "/var/log", "*.log", "ERROR|FAIL")

Esto lee contenidos de archivos. Lento para archivos grandes. Potente para búsquedas específicas.


Parte 5: Operaciones de archivo seguras — Atomicidad y manejo de errores

Las operaciones de archivo remotas pueden fallar en medio de una transferencia. Necesitas patrones para seguridad.

Cargas atómicas con archivos temp

// SafeUpload carga un archivo de forma atómica usando archivo temp
func SafeUpload(client *sftp.Client, localPath, remotePath string) error {
	// Lee archivo local
	data, err := os.ReadFile(localPath)
	if err != nil {
		return fmt.Errorf("read local: %w", err)
	}

	// Escribe a archivo temp primero
	tempPath := remotePath + ".tmp"
	tempFile, err := client.Create(tempPath)
	if err != nil {
		return fmt.Errorf("create temp: %w", err)
	}

	_, err = tempFile.Write(data)
	tempFile.Close()
	if err != nil {
		client.Remove(tempPath) // Limpia en error
		return fmt.Errorf("write temp: %w", err)
	}

	// Rename atómico
	err = client.Rename(tempPath, remotePath)
	if err != nil {
		client.Remove(tempPath) // Limpia en error
		return fmt.Errorf("rename: %w", err)
	}

	return nil
}

Esto asegura que el archivo destino está completo o no existe. Nunca parcial.

Descargas seguras con checksums

// SafeDownload descarga con verificación de checksum
func SafeDownload(client *sftp.Client, remotePath, localPath string) error {
	// Descarga a archivo temp
	tempPath := localPath + ".tmp"
	tempFile, err := os.Create(tempPath)
	if err != nil {
		return fmt.Errorf("create temp: %w", err)
	}
	defer tempFile.Close()

	remoteFile, err := client.Open(remotePath)
	if err != nil {
		os.Remove(tempPath)
		return fmt.Errorf("open remote: %w", err)
	}
	defer remoteFile.Close()

	// Copia con hash
	h := md5.New()
	w := io.MultiWriter(tempFile, h)
	_, err = io.Copy(w, remoteFile)
	if err != nil {
		os.Remove(tempPath)
		return fmt.Errorf("copy: %w", err)
	}

	remoteHash := getRemoteChecksum(client, remotePath)
	localHash := fmt.Sprintf("%x", h.Sum(nil))

	if remoteHash != localHash {
		os.Remove(tempPath)
		return fmt.Errorf("checksum mismatch: %s != %s", remoteHash, localHash)
	}

	// Rename atómico
	err = os.Rename(tempPath, localPath)
	if err != nil {
		os.Remove(tempPath)
		return fmt.Errorf("rename: %w", err)
	}

	return nil
}

Esto previene descargas parciales de ser usadas.


Parte 6: Herramienta CLI — Un ejemplo práctico

Construye una herramienta de línea de comandos para operaciones SFTP:

// sftp-tool/main.go
package main

import (
	"flag"
	"fmt"
	"golang.org/x/crypto/ssh"
	"github.com/pkg/sftp"
)

func main() {
	cmd := flag.NewFlagSet("sftp-tool", flag.ExitOnError)
	host := cmd.String("host", "", "Host remoto")
	user := cmd.String("user", "", "Usuario")
	operation := cmd.String("op", "", "Operación: list, search, download, upload")
	remotePath := cmd.String("remote", "", "Ruta remota")
	localPath := cmd.String("local", "", "Ruta local")
	pattern := cmd.String("pattern", "*", "Patrón de búsqueda")

	cmd.Parse(flag.Args())

	// Crea config SSH
	config := &ssh.ClientConfig{
		User: *user,
		Auth: []ssh.AuthMethod{
			ssh.Password("password"), // Usa auth basada en clave en producción
		},
		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
	}

	// Conecta
	sshClient, err := ssh.Dial("tcp", *host+":22", config)
	if err != nil {
		panic(err)
	}
	defer sshClient.Close()

	sftpClient, err := sftp.NewClient(sshClient)
	if err != nil {
		panic(err)
	}
	defer sftpClient.Close()

	// Ejecuta operación
	switch *operation {
	case "list":
		listDir(sftpClient, *remotePath)
	case "search":
		searchFiles(sftpClient, *remotePath, *pattern)
	case "download":
		downloadFile(sftpClient, *remotePath, *localPath)
	case "upload":
		uploadFile(sftpClient, *localPath, *remotePath)
	default:
		fmt.Println("Operación desconocida")
	}
}

func listDir(client *sftp.Client, path string) {
	files, _ := client.ReadDir(path)
	for _, f := range files {
		fmt.Printf("%s %d %v\n", f.Name(), f.Size(), f.ModTime())
	}
}

func searchFiles(client *sftp.Client, dir, pattern string) {
	client.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return nil
		}
		if matched, _ := filepath.Match(pattern, filepath.Base(path)); matched {
			fmt.Println(path)
		}
		return nil
	})
}

func downloadFile(client *sftp.Client, remote, local string) {
	file, _ := client.Open(remote)
	defer file.Close()
	out, _ := os.Create(local)
	defer out.Close()
	io.Copy(out, file)
	fmt.Println("Descargado:", local)
}

func uploadFile(client *sftp.Client, local, remote string) {
	file, _ := os.Open(local)
	defer file.Close()
	out, _ := client.Create(remote)
	defer out.Close()
	io.Copy(out, file)
	fmt.Println("Cargado:", remote)
}

Uso:

# Listar directorio
./sftp-tool -host example.com -user admin -op list -remote /var/log

# Buscar archivos
./sftp-tool -host example.com -user admin -op search -remote /var/log -pattern "*.txt"

# Descargar archivo
./sftp-tool -host example.com -user admin -op download -remote /var/log/app.log -local ./app.log

# Cargar archivo
./sftp-tool -host example.com -user admin -op upload -local ./config.txt -remote /etc/config.txt

Parte 7: Optimización de rendimiento — Afinando tu código

Operaciones en lote

En lugar de una operación a la vez:

// LENTO: Una descarga a la vez
for _, file := range files {
	downloadFile(sftpClient, file, localDir)
}

// RÁPIDO: Descargas en lote con concurrencia
func batchDownload(client *sftp.Client, files []string, dest string, workers int) error {
	sem := make(chan struct{}, workers) // Semáforo para límite de concurrencia

	var wg sync.WaitGroup
	errChan := make(chan error, len(files))

	for _, file := range files {
		wg.Add(1)
		go func(f string) {
			defer wg.Done()

			sem <- struct{}{}        // Adquiere
			defer func() { <-sem }() // Libera

			localName := filepath.Join(dest, filepath.Base(f))
			if err := SafeDownload(client, f, localName); err != nil {
				errChan <- err
			}
		}(file)
	}

	wg.Wait()
	close(errChan)

	for err := range errChan {
		if err != nil {
			return err
		}
	}

	return nil
}

Esto descarga múltiples archivos concurrentemente mientras limita concurrencia para no sobrecargar la conexión.

Keepalive de conexión

Mantén conexiones vivas sobre operaciones largas:

// Añade keepalive
config := &ssh.ClientConfig{
	ClientVersion: "SSH-2.0-sftptool",
	// ... métodos de auth ...
}

// Después de dial, añade keepalive
sshClient.OpenChannel("session", nil) // Canal dummy para mantener conexión viva

// O usa ping periódico
go func() {
	ticker := time.NewTicker(30 * time.Second)
	defer ticker.Stop()
	for range ticker.C {
		sshClient.SendRequest("keepalive@openssh.com", true, nil)
	}
}()

Parte 8: Resumen de mejores prácticas

Gestión de conexiones:

  • ✅ Usa pooling de conexiones, no una conexión por operación
  • ✅ Reutiliza conexiones en múltiples operaciones
  • ✅ Implementa limpieza y manejo de errores apropiado

Operaciones de archivo:

  • ✅ Siempre usa archivos temp para cargas (escrituras atómicas)
  • ✅ Siempre verifica descargas con checksums
  • ✅ Maneja fallos parciales gracefully

Estrategias de búsqueda:

  • ✅ Búsqueda solo nombre para velocidad
  • ✅ Búsqueda paralela de directorio para múltiples árboles
  • ✅ Búsqueda de contenido solo cuando sea necesario

Rendimiento:

  • ✅ Operaciones en lote
  • ✅ Usa transferencias concurrentes con semáforos
  • ✅ Mantén conexiones vivas sobre operaciones largas
  • ✅ Haz profile antes de optimizar

Seguridad:

  • ✅ Usa autenticación basada en clave, no contraseñas
  • ✅ Verifica host keys en producción
  • ✅ Limita permisos de archivo después de transferencia
  • ✅ Encripta datos sensibles antes de transferir

Parte 9: El costo real del código SFTP malo

Los desarrolladores a menudo tratan SFTP como una herramienta simple. “Abre conexión, transfiere archivo, cierra.”

A escala, esto es costoso:

  • Autenticación por operación: 100ms cada una
  • 100 archivos a descargar: 10+ segundos en serie
  • Transferencias fallidas: Sin recuperación, comienza de nuevo
  • Archivos parciales: Corrompen datos silenciosamente

Los patrones correctos cuestan algunos cientos de líneas de código. Ahorran horas de debugging y días de datos perdidos.

La diferencia entre código SFTP que funciona y código SFTP que escala no es complejidad. Es disciplina. Connection pooling. Operaciones atómicas. Manejo de errores apropiado. Estos no son opcionales. Son la diferencia entre código que funciona y código de producción.