Automatizar el sondeo de archivos SFTP con Ruby
Importar archivos directamente desde servidores SFTP remotos puede ser una tarea repetitiva si se hace de forma manual. En lugar de ejecutar scripts puntuales, puedes automatizar este proceso sondeando continuamente un directorio remoto y descargando los archivos nuevos a medida que aparecen. En esta publicación construiremos paso a paso un script de Ruby que aprovecha la gema Net::SFTP para realizar importaciones de archivos seguras y automatizadas.
Requisitos previos y configuración
Para seguir esta guía, asegúrate de contar con lo siguiente:
- Ruby 3.1 o posterior. La gema
net-sftpincorpora una versión denet-sshque necesita un intérprete actual, y el ejemplo de Docker de abajo se basa enruby:3.2-slim. - Gema Net::SFTP (versión 4.0.0 o posterior):
gem install net-sftp -v '~> 4.0.0' - Una clave SSH válida para la autenticación sin contraseña
- La clave de host del servidor en tu archivo
known_hosts:ssh-keyscan -p 22 sftp.example.com >> ~/.ssh/known_hosts, verificada contra una huella digital que obtuviste por un canal independiente - Conocimientos básicos de Ruby y de operaciones en la línea de comandos
Además, asegúrate de que tu servidor SFTP esté configurado para aceptar tu clave SSH y de que tengas los permisos necesarios para acceder al directorio de destino.
Crear el sondeador de archivos
El objetivo es crear un script de Ruby que se conecte a un servidor SFTP, examine un directorio designado en busca de archivos, los descargue en una carpeta local y luego espere antes de volver a sondear. Incorporaremos registro estructurado, manejo de errores que sobrevive a un archivo defectuoso y las comprobaciones de seguridad que necesita un importador de larga duración.
Este es un ejemplo completo del script de Ruby con las mejores prácticas modernas:
Este sondeador puede eliminar cada archivo del servidor remoto después de descargarlo, algo
irreversible y que es el comportamiento incorrecto para un directorio de entrega compartido. Ese
paso está desactivado de forma predeterminada; establece DELETE_AFTER_DOWNLOAD=true solo cuando este script sea el
propietario del directorio remoto. La activación explícita por sí sola no basta: un productor que
reescriba un nombre entre la descarga y la eliminación hace que borres una revisión que nunca
importaste. Actívalo solo cuando los productores escriban cada archivo una sola vez, con un nombre
que nunca reutilicen.
require 'net/sftp'
require 'logger'
require 'json'
require 'tempfile'
require 'fileutils'
require 'time' # Time#iso8601, used by the log formatter below
# Configuration constants
SFTP_HOST = ENV.fetch('SFTP_HOST')
SFTP_USER = ENV.fetch('SFTP_USER')
SFTP_PORT = ENV.fetch('SFTP_PORT', 22).to_i
REMOTE_DIR = ENV.fetch('REMOTE_DIR')
LOCAL_DIR = ENV.fetch('LOCAL_DIR', './downloads')
SSH_KEY = ENV.fetch('SSH_KEY_PATH')
KNOWN_HOSTS = ENV.fetch('SSH_KNOWN_HOSTS', File.expand_path('~/.ssh/known_hosts'))
# Removing the remote copy cannot be undone, so it has to be asked for explicitly.
DELETE_AFTER_DOWNLOAD = ENV.fetch('DELETE_AFTER_DOWNLOAD', 'false') == 'true'
# Initialize structured logger
logger = Logger.new(STDOUT)
logger.formatter = proc do |severity, datetime, progname, msg|
JSON.dump(
timestamp: datetime.iso8601,
severity: severity,
message: msg,
service: 'sftp-poller'
) + "\n"
end
def download_file(sftp, remote_file, final_path, logger)
temp_path = nil
begin
# Stage inside the destination directory so the final rename stays on one filesystem. That is
# what makes it atomic; Tempfile's default directory is usually a different mount.
temp = Tempfile.create('sftp-download', File.dirname(final_path))
temp_path = temp.path
temp.close
sftp.download!(remote_file, temp_path)
File.rename(temp_path, final_path)
temp_path = nil
logger.info({ action: 'download_complete', file: remote_file, destination: final_path })
true
rescue Net::SFTP::StatusException => e
logger.error({ action: 'download_failed', file: remote_file, error: e.message, code: e.code })
false
rescue SystemCallError, IOError => e
# A local failure (permissions, a full disk) must not take the whole poller down.
logger.error({ action: 'download_failed', file: remote_file, error: e.message })
false
ensure
File.unlink(temp_path) if temp_path && File.exist?(temp_path)
end
end
def with_retries(max_attempts: 3, base_delay: 1, logger:)
attempt = 0
begin
attempt += 1
yield
rescue Net::SSH::AuthenticationFailed => e
logger.error({ action: 'authentication_failed', error: e.message })
raise
rescue Errno::ECONNREFUSED, Net::SSH::ConnectionTimeout => e
if attempt < max_attempts
delay = base_delay * (2 ** (attempt - 1))
logger.warn({ action: 'retry_attempt', attempt: attempt, delay: delay, error: e.message })
sleep delay
retry
end
logger.error({ action: 'max_retries_reached', error: e.message })
raise
end
end
# Entry names come from the server, so only a plain basename may be joined onto REMOTE_DIR and
# LOCAL_DIR. A name such as `a/../../escaped.txt` is neither hidden nor a directory, yet it reads
# outside REMOTE_DIR, writes outside LOCAL_DIR, and with deletion enabled removes the wrong remote
# file. Reject those names rather than trying to repair them.
def plain_basename?(name)
return false if name.nil? || name.empty?
return false if name.include?('/') || name.include?('\\')
return false if name == '.' || name == '..'
File.basename(name) == name
end
def poll_sftp(logger)
with_retries(logger: logger) do
Net::SFTP.start(
SFTP_HOST,
SFTP_USER,
port: SFTP_PORT,
keys: [SSH_KEY],
keys_only: true,
# Refuse to connect to a host whose key is not already trusted.
verify_host_key: :always,
user_known_hosts_file: KNOWN_HOSTS
) do |sftp|
logger.info({ action: 'connection_established', host: SFTP_HOST, directory: REMOTE_DIR })
sftp.dir.foreach(REMOTE_DIR) do |entry|
# A leading dot is usually a producer's partial upload, so skip those quietly.
next if entry.name.start_with?('.')
unless plain_basename?(entry.name)
logger.warn({ action: 'entry_rejected', file: entry.name })
next
end
# download! raises on directories, which would otherwise break every future cycle too.
next unless entry.attributes.file?
remote_file = File.join(REMOTE_DIR, entry.name)
local_file = File.join(LOCAL_DIR, entry.name)
next unless download_file(sftp, remote_file, local_file, logger)
next unless DELETE_AFTER_DOWNLOAD
begin
sftp.remove!(remote_file)
logger.info({ action: 'remote_file_removed', file: remote_file })
rescue Net::SFTP::StatusException => e
logger.error({ action: 'remove_failed', file: remote_file, error: e.message })
end
end
end
end
end
# Ensure the local download directory exists
FileUtils.mkdir_p(LOCAL_DIR)
# Set up signal handling for graceful shutdown
@shutdown = false
Signal.trap('TERM') { @shutdown = true }
Signal.trap('INT') { @shutdown = true }
# Main polling loop with graceful shutdown
until @shutdown
poll_sftp(logger)
logger.info({ action: 'polling_wait', delay: 60 })
sleep 60
end
logger.info({ action: 'shutdown_complete' })
Entender el script
Este script implementa varias funcionalidades importantes:
-
Configuración basada en el entorno: usa variables de entorno para la configuración sensible, siguiendo las mejores prácticas de seguridad.
-
Registro estructurado: emite un objeto JSON por evento, con los campos del evento anidados bajo
messageen lugar de serializados en una cadena, para que los agregadores de registros puedan indexarlos. -
Operaciones atómicas de archivos: prepara cada descarga junto a su destino y la renombra en su sitio. El renombrado solo es atómico dentro de un mismo sistema de archivos, razón por la cual el archivo temporal se crea en
LOCAL_DIRen lugar de/tmp. Por eso los consumidores nunca observan un archivo parcial. Ten en cuenta queTempfile.createusa el modo0600y que el renombrado lo conserva, así que añade unFile.chmodantes del renombrado si otra cuenta tiene que leer lo que importaste. Esto solo se cumple si el script es el propietario exclusivo deLOCAL_DIR: apúntalo a un directorio en el que nada más escriba y haz que los consumidores saquen los archivos en lugar de crear subdirectorios dentro de él. La atomicidad también termina en el extremo remoto. Un productor que reescriba un archivo mientras se está leyendo te entrega una mezcla de dos revisiones, así que haz que los productores suban con un nombre temporal y renombren aREMOTE_DIRuna vez que los bytes estén ahí. -
Nombres de entrada no confiables:
plain_basename?rechaza todo lo que el servidor liste y que no sea un nombre de archivo simple. Sin él, un nombre comoa/../../escaped.txtsupera tanto la comprobación de archivos ocultos comoattributes.file?, luego escapa deLOCAL_DIRal concatenarse, lee una ruta fuera deREMOTE_DIRy, con la eliminación activada, borra un archivo que nunca pediste. -
Manejo robusto de errores: usa tipos de error específicos y reintenta los fallos de conexión con retroceso exponencial. Una descarga fallida se registra y se omite en lugar de lanzarse, de modo que un archivo defectuoso no puede terminar el bucle de sondeo.
-
Apagado controlado: gestiona correctamente las señales de terminación para una administración limpia del proceso.
-
Limpieza remota opcional: elimina del servidor remoto los archivos descargados correctamente para evitar el procesamiento duplicado, pero solo cuando
DELETE_AFTER_DOWNLOAD=true. La eliminación apunta a un nombre, no a la revisión que se descargó, así que depende de la misma disciplina de escritura única que la advertencia anterior. Dejarla desactivada significa que necesitas otra forma de evitar el reprocesamiento, como un registro local de los nombres de archivo importados.
Despliegue en producción
Para entornos de producción, considera estas opciones de despliegue:
Usar Systemd
Crea un archivo de servicio de systemd para una gestión fiable del proceso:
[Unit]
Description=SFTP File Poller
After=network.target
[Service]
Type=simple
User=sftp-user
Environment=SFTP_HOST=example.com
Environment=SFTP_USER=username
Environment=REMOTE_DIR=/remote/path
Environment=LOCAL_DIR=/local/path
Environment=SSH_KEY_PATH=/path/to/key
Environment=SSH_KNOWN_HOSTS=/path/to/known_hosts
ExecStart=/usr/bin/ruby /path/to/sftp_poller.rb
Restart=always
RestartSec=60
[Install]
WantedBy=multi-user.target
Usar docker
Crea un Dockerfile para el despliegue en contenedores:
FROM ruby:3.2-slim
RUN gem install net-sftp -v '~> 4.0.0'
WORKDIR /app
COPY sftp_poller.rb .
CMD ["ruby", "sftp_poller.rb"]
Mejores prácticas de seguridad
-
Verificación de la clave de host:
- Mantén
verify_host_key: :alwayspara que una clave de host desconocida o modificada aborte la conexión - Aprovisiona
known_hostsa partir de una huella digital que hayas confirmado por un canal independiente, no a partir de lo que presente la primera conexión - Distribuye
known_hostsjunto con el despliegue (consulta la variableSSH_KNOWN_HOSTSde arriba) en lugar de depender del directorio personal del usuario que ejecuta el proceso
- Mantén
-
Gestión de claves SSH:
- Rota las claves SSH con regularidad
- Las claves Ed25519 requieren las gemas opcionales
ed25519ybcrypt_pbkdfde Net::SSH. El Dockerfile mínimo de arriba no las instala; añade esas gemas y las herramientas de compilación de extensiones nativas antes de usar ese tipo de clave, o usa una clave RSA compatible con firmas SHA-2. - Almacena las claves de forma segura mediante variables de entorno o bóvedas seguras
-
Seguridad de la red:
- Restringe el acceso SFTP a rangos de IP específicos
- Usa cifrados robustos y algoritmos de intercambio de claves
- Implementa tiempos de espera de conexión
-
Acceso a los archivos:
- Usa permisos mínimos tanto para los archivos locales como para los remotos
- Implementa comprobaciones de integridad de los archivos
- Limpia correctamente los archivos temporales
-
Monitoreo:
- Configura alertas para las descargas fallidas y los problemas de conexión
- Monitorea el uso del espacio en disco
- Haz seguimiento de las métricas de procesamiento
Conclusión
Este script de Ruby es un punto de partida para importaciones automatizadas de archivos por SFTP. Cubre los casos que suelen dar problemas primero: la verificación de la clave de host, los nombres de entrada que controla el servidor, una escritura local que está completa o no existe, y un fallo que no debe terminar el bucle de sondeo. La deduplicación entre reinicios, los límites de espacio en disco y las alertas sobre un sondeador que se ha quedado en silencio siguen siendo cosa tuya.
Para quienes buscan una solución gestionada, Transloadit ofrece un SFTP Import Robot que se encarga de estas complejidades automáticamente. Obtén más información aquí.
