Automatizar el sondeo de archivos SFTP con Ruby
Usa Net::SFTP para sondear un directorio de recepción de confianza y publicar archivos completos en un directorio local. El siguiente script verifica la clave de host del servidor, prepara las descargas antes de reemplazar los archivos locales y conserva los archivos remotos, a menos que habilites explícitamente su eliminación. Vuelve a sondear todos los archivos aptos tras cada espera de 60 segundos; no recuerda qué archivos ya importaste.
Requisitos previos y configuración
Este ejemplo usa Linux, Ruby 3.4.10 con soporte para OpenSSL y Bundler 2.6.9. Ruby 3.4 es una serie de versiones con mantenimiento activo; Ruby 3.1 y 3.2 han llegado al final de su vida útil. Ten a mano un compilador y los archivos de cabecera de desarrollo de Ruby para las extensiones nativas de las gemas.
Necesitas una cuenta SFTP existente cuyo administrador haya autorizado tu clave pública y concedido
acceso de lectura y listado a un directorio de recepción. Para configurar el servidor y autorizar
claves, consulta el manual del servidor OpenSSH. Este ejemplo sin
supervisión usa una clave privada dedicada, sin cifrar y protegida mediante permisos del sistema
de archivos. Obtén la clave pública de host del servidor o su huella digital a través de un canal
de confianza con su administrador. Si obtienes una clave candidata con ssh-keyscan,
verifícala antes de añadirla a un archivo known_hosts dedicado; un escaneo por sí
solo no establece confianza. Para un puerto no estándar, su entrada de host debe usar
[hostname]:port.
Comienza en un directorio nuevo y guarda esto como Gemfile. Estas son las
versiones usadas aquí, incluidas las
dependencias de Ed25519 de Net::SSH 7.3.0:
source 'https://rubygems.org'
gem 'net-sftp', '4.0.0'
gem 'net-ssh', '7.3.0'
gem 'ed25519', '1.4.0'
gem 'bcrypt_pbkdf', '1.1.1'
gem 'base64', '0.3.0'
Instala el conjunto de gemas en ese directorio y luego conserva el archivo
Gemfile.lock generado junto con el script:
bundle install
Usa un destino local de uso exclusivo de este importador. Los productores deben terminar de subir cada archivo con un nombre que comience por punto, renombrarlo a su nombre definitivo y dejar sus bytes publicados sin cambios. Este script no bloquea los archivos remotos ni detecta si un productor modifica un archivo mientras se está leyendo.
Crea el sondeador de archivos
Guarda lo siguiente como sftp_poller.rb junto a Gemfile.
Importa archivos regulares ubicados directamente dentro de REMOTE_DIR y omite
archivos cuyos nombres comienzan por punto, subdirectorios y enlaces simbólicos. Cada descarga
correcta reemplaza cualquier archivo local existente con el mismo nombre. Si una descarga falla,
el archivo local anterior se conserva.
Este sondeador puede eliminar cada archivo del servidor remoto después de descargarlo, lo cual
es irreversible e inadecuado para un directorio de recepción compartido. Esa acción está
desactivada de forma predeterminada; establece DELETE_AFTER_DOWNLOAD=true solo cuando este
script tenga el uso exclusivo del directorio remoto. Activar la opción por sí solo no basta:
si un productor reescribe un archivo con el mismo nombre entre la descarga y la eliminación,
terminarás eliminando una revisión que nunca importaste. Habilítala 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
STDOUT.sync = true
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,
config: false,
use_agent: false,
auth_methods: ['publickey'],
non_interactive: true,
timeout: 10,
# Refuse to connect to a host whose key is not already trusted.
verify_host_key: :always,
user_known_hosts_file: KNOWN_HOSTS,
global_known_hosts_file: []
) do |sftp|
logger.info({ action: 'connection_established', host: SFTP_HOST, directory: REMOTE_DIR })
sftp.dir.foreach(REMOTE_DIR) do |entry|
break if @shutdown
# 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)
break if @shutdown
logger.info({ action: 'polling_wait', delay: 60 })
60.times do
break if @shutdown
sleep 1
end
end
logger.info({ action: 'shutdown_complete' })
Reemplaza los datos de conexión y las rutas absolutas que aparecen a continuación y luego ejecuta
esto desde el directorio del script. SSH_KEY_PATH indica el archivo de clave
privada; no pongas el contenido de la clave en una variable de entorno.
SFTP_HOST=sftp.example.com \
SFTP_PORT=22 \
SFTP_USER=importer \
REMOTE_DIR=/drop \
LOCAL_DIR=/srv/sftp-import/downloads \
SSH_KEY_PATH=/srv/sftp-import/id_ed25519 \
SSH_KNOWN_HOSTS=/srv/sftp-import/known_hosts \
DELETE_AFTER_DOWNLOAD=false \
bundle exec ruby sftp_poller.rb
Después de un sondeo correcto, los registros JSON contienen connection_established, un
evento download_complete por cada archivo importado y polling_wait.
Verifica los archivos de destino indicados, incluidos sus bytes, antes de conectar un consumidor.
Un evento de finalización significa que el cambio de nombre local se realizó correctamente; no
significa que el procesamiento posterior o la eliminación remota opcional se hayan completado
correctamente. Los archivos vacíos son importaciones válidas.
Comprende el script
Este script implementa varias funciones importantes:
-
Configuración basada en el entorno: Usa variables de entorno para la configuración sensible, siguiendo las buenas prácticas de seguridad.
-
Registros estructurados: 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 con archivos: Prepara cada descarga junto a su destino y la renombra a su nombre definitivo. El cambio de nombre solo es atómico dentro de un mismo sistema de archivos; por eso el archivo temporal se crea en
LOCAL_DIRen lugar de/tmp. Los consumidores deben ignorar los nombres que comienzan consftp-download, que corresponden a archivos temporales; reserva ese prefijo y no lo uses para nombres de entrada. Solo los nombres de archivo definitivos se publican de forma atómica. Ten en cuenta queTempfile.createusa el modo0600y que el cambio de nombre lo conserva, así que añadeFile.chmodantes del cambio de nombre si otra cuenta debe leer lo que importaste. Esto solo se cumple si el script tiene el uso exclusivo deLOCAL_DIR: configúralo para que use un directorio en el que nada más escriba y haz que los consumidores muevan los archivos fuera de él en lugar de crear subdirectorios dentro. La atomicidad tampoco se extiende al extremo remoto. Un productor que reescribe un archivo mientras se está leyendo te entrega una mezcla de dos revisiones, así que haz que los productores suban los archivos con un nombre temporal y los renombren dentro deREMOTE_DIRuna vez que todos los bytes estén allí. El reemplazo atómico no garantiza la durabilidad ante un corte de energía: el script no llama afsync. La documentación de Tempfile de Ruby explica los permisos y la limpieza explícita que se usan aquí. -
Nombres de entrada no confiables:
plain_basename?rechaza cualquier entrada que el servidor liste y que no sea un nombre de archivo simple. Sin esta validación, un nombre comoa/../../escaped.txtsupera tanto la comprobación de nombres que comienzan por punto comoattributes.file?; luego, al combinarlo con la ruta, sale deLOCAL_DIR, lee una ruta fuera deREMOTE_DIRy, si la eliminación está habilitada, elimina un archivo que nunca solicitaste. -
Manejo de errores: Reintenta las conexiones rechazadas y aquellas cuyo tiempo de espera se agota, hasta un máximo de tres intentos, esperando un segundo y luego dos segundos. Los errores de estado SFTP y del sistema de archivos local durante una descarga individual se registran y esa entrada se omite, de modo que la siguiente aún se pueda importar. Las claves de host desconocidas o modificadas, los fallos de autenticación, los fallos al listar el directorio y las conexiones SSH interrumpidas terminan el proceso. Resuelve la causa antes de reiniciarlo; no omitas las verificaciones del host.
-
Detención: Ctrl+C o
SIGTERMsolicita la detención. Durante la espera entre sondeos, el script comprueba esa solicitud una vez por segundo. Durante una transferencia, termina el archivo actual y cualquier eliminación que se haya habilitado y luego se detiene antes de pasar a otra entrada. El tiempo de espera de conexión limita el establecimiento de la conexión inicial, no la transferencia completa, así que un servidor que no responde puede retrasar la detención. Una excepción normal ejecuta la limpieza de archivos temporales;SIGKILLo un fallo del equipo no pueden hacerlo. Después de una detención forzada, elimina los archivossftp-download*restantes solo mientras el importador esté detenido. -
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 se dirige a un nombre, no a la revisión que se descargó, por lo que depende de la misma disciplina de escritura única que la advertencia anterior. Si dejas esta opción desactivada, necesitarás otra forma de evitar el reprocesamiento, como un registro local de los nombres de archivo importados.
Despliegue en producción
El ejemplo ejecutable anterior es un proceso en primer plano. Antes de gestionarlo con un supervisor, decide cómo evitarán los consumidores el procesamiento duplicado, monitorea tanto los errores de cada archivo como las terminaciones del proceso y proporciona almacenamiento duradero para las descargas. Ejecuta solo un importador por destino. Las siguientes son consideraciones de empaquetado, no configuraciones completas de despliegue.
Usa Systemd
La identidad del servicio necesita acceso de lectura a la clave, al archivo de hosts de confianza, al script y al conjunto de gemas instalado, además de acceso de escritura al destino. Usa el directorio del script como directorio de trabajo e invócalo mediante Bundler. Ajusta el período de gracia para la detención según tus transferencias; una solicitud de detención no cancela una descarga activa. Si un supervisor termina forzosamente un importador que no responde, puede quedar un archivo temporal, por lo que la recuperación debe contemplar esa posibilidad.
Usa docker
Usa una imagen de Ruby con mantenimiento activo, instala el mismo conjunto de gemas con versiones
fijadas y mantén las credenciales fuera de la imagen. Monta la clave y el archivo de hosts de
confianza en modo de solo lectura y conserva el directorio de descargas en un volumen persistente.
Asegúrate de que Ruby reciba la señal de detención. El período de gracia predeterminado de Docker
para la detención en Linux es de solo 10 segundos; después envía SIGKILL.
Configura el período de gracia según tu carga de trabajo y prueba una transferencia activa, no
solo un contenedor inactivo. Consulta el
comportamiento de detención de Docker.
Buenas 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 interrumpa la conexión - Prepara
known_hostsa partir de una huella digital que hayas confirmado mediante un canal independiente, no a partir de lo que presente la primera conexión - Incluye
known_hostsen el despliegue (consulta la variableSSH_KNOWN_HOSTSanterior) 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
- El conjunto de gemas con versiones fijadas incluye las gemas opcionales necesarias para las claves Ed25519 en Net::SSH 7.3.0
- Restringe el acceso al archivo de clave privada a la cuenta del importador
-
Seguridad de la red:
- Restringe el acceso SFTP a rangos de IP específicos
- Usa cifrados y algoritmos de intercambio de claves seguros
- Monitorea las transferencias que se estanquen; el tiempo de espera de conexión inicial no es un plazo límite para la transferencia
-
Acceso a archivos:
- Usa permisos mínimos tanto para los archivos locales como para los remotos
- Implementa verificaciones de integridad de archivos
- Limpia los archivos temporales correctamente
-
Monitoreo:
- Configura alertas para descargas fallidas y problemas de conexión
- Monitorea el uso del espacio en disco
- Da seguimiento a las métricas de procesamiento
Conclusión
Este script de Ruby es un punto de partida para automatizar la importación de archivos por SFTP. Cubre los casos que suelen causar problemas primero: la verificación de la clave de host, los nombres de entrada que controla el servidor, una escritura local que se completa por entero o no se realiza y un fallo que no debe terminar el bucle de sondeo. Aún te corresponde añadir la deduplicación entre reinicios, los límites de espacio en disco y las alertas cuando el sondeador deje de dar señales de actividad.
Si ya usas Transloadit, consulta la documentación de importación por SFTP.
