Automatiser l’interrogation périodique de fichiers SFTP en Ruby
Utilisez Net::SFTP pour interroger un répertoire de dépôt de confiance et publier les fichiers complets dans un répertoire local. Le script ci-dessous vérifie la clé d’hôte du serveur, prépare les téléchargements dans un fichier temporaire avant de remplacer les fichiers locaux, et conserve les fichiers distants sauf si vous activez explicitement leur suppression. Il interroge de nouveau tous les fichiers éligibles après chaque attente de 60 secondes ; il ne mémorise pas les fichiers que vous avez déjà importés.
Prérequis et configuration
Cet exemple utilise Linux, Ruby 3.4.10 avec la prise en charge d’OpenSSL, et Bundler 2.6.9. Ruby 3.4 est une série de versions maintenue ; Ruby 3.1 et Ruby 3.2 sont en fin de vie. Prévoyez un compilateur et les en-têtes de développement Ruby pour les extensions natives des gems.
Vous avez besoin d’un compte SFTP existant dont l’administrateur a autorisé votre clé publique et
vous a accordé un accès en lecture et en listage à un répertoire de dépôt. Pour la configuration du
serveur et l’autorisation des clés, consultez le
manuel du serveur OpenSSH.
Cet exemple, conçu pour fonctionner sans surveillance, utilise une clé privée dédiée et non
chiffrée, protégée par les permissions du système de fichiers. Obtenez la clé d’hôte publique du
serveur ou son empreinte auprès de son administrateur par un canal de confiance. Si vous récupérez
une clé candidate avec ssh-keyscan, vérifiez-la avant de l’ajouter à un fichier
known_hosts dédié ; une simple analyse ne suffit pas à établir la confiance. Pour
un port non standard, l’entrée d’hôte correspondante doit utiliser [hostname]:port.
Commencez dans un nouveau répertoire et enregistrez ceci sous Gemfile. Voici
les versions utilisées ici, y compris
les dépendances 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'
Installez le bundle dans ce répertoire, puis conservez le fichier Gemfile.lock
généré avec le script :
bundle install
Utilisez une destination locale réservée exclusivement à cet importateur. Les producteurs doivent terminer leur envoi sous un nom commençant par un point, puis le renommer à son emplacement final, et ne plus modifier ensuite les octets publiés. Ce script ne verrouille pas les fichiers distants et ne détecte pas un producteur qui modifie un fichier pendant sa lecture.
Création du script d’interrogation des fichiers
Enregistrez le code suivant sous sftp_poller.rb à côté de
Gemfile. Il importe les fichiers ordinaires situés directement dans
REMOTE_DIR, en ignorant les fichiers cachés (dotfiles), les sous-répertoires et
les liens symboliques. Chaque téléchargement réussi remplace tout fichier local existant portant le
même nom. Un téléchargement échoué laisse le fichier local précédent en place.
Ce script d’interrogation peut supprimer chaque fichier du serveur distant après l’avoir
téléchargé, ce qui est irréversible et constitue le mauvais comportement pour un répertoire de
dépôt partagé. Cette étape est désactivée par défaut ; définissez DELETE_AFTER_DOWNLOAD=true
uniquement lorsque ce script est propriétaire du répertoire distant. Cette activation explicite ne
suffit pas à elle seule : si un producteur réécrit un nom entre le téléchargement et la
suppression, vous supprimez une révision que vous n’avez jamais importée. N’activez cette option
que si les producteurs écrivent chaque fichier une seule fois, sous un nom qu’ils ne réutilisent
jamais.
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' })
Remplacez les informations de connexion et les chemins absolus ci-dessous, puis exécutez ceci depuis
le répertoire du script. SSH_KEY_PATH désigne le fichier de clé privée ; ne placez
pas le contenu de la clé dans une variable d’environnement.
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
Après un passage réussi, les journaux JSON contiennent connection_established, un événement
download_complete pour chaque fichier importé, et polling_wait.
Vérifiez les fichiers de destination nommés, y compris leurs octets, avant de brancher un
consommateur. Un événement de fin signifie que le renommage local a réussi ; il ne signifie pas que
le traitement en aval ou la suppression distante facultative a réussi. Les fichiers vides sont des
importations valides.
Comprendre le script
Ce script met en œuvre plusieurs fonctionnalités importantes :
-
Configuration par variables d’environnement : Utilise des variables d’environnement pour la configuration sensible, conformément aux bonnes pratiques de sécurité.
-
Journalisation structurée : Émet un objet JSON par événement, avec les champs de l’événement imbriqués sous
messageau lieu d’être sérialisés dans une chaîne, afin que les agrégateurs de journaux puissent les indexer. -
Opérations de fichiers atomiques : Prépare chaque téléchargement à côté de sa destination, puis le renomme à son emplacement final. Le renommage n’est atomique qu’au sein d’un même système de fichiers, c’est pourquoi le fichier temporaire est créé dans
LOCAL_DIRet non dans/tmp. Les consommateurs doivent ignorer les noms commençant parsftp-download, qui sont des fichiers temporaires ; réservez ce préfixe et ne l’utilisez pas pour les noms d’entrée. Seuls les noms de fichiers finaux sont publiés de manière atomique. Notez queTempfile.createutilise le mode0600, que le renommage conserve ; ajoutez donc unFile.chmodavant le renommage si un autre compte doit lire ce que vous avez importé. Cela n’est valable que si le script est seul propriétaire deLOCAL_DIR: pointez-le vers un répertoire dans lequel rien d’autre n’écrit, et demandez aux consommateurs de déplacer les fichiers hors de ce répertoire plutôt que d’y créer des sous-répertoires. L’atomicité s’arrête aussi du côté distant. Un producteur qui réécrit un fichier pendant sa lecture vous transmet un mélange de deux révisions ; demandez donc aux producteurs d’envoyer leurs fichiers sous un nom temporaire, puis de les renommer dansREMOTE_DIRune fois les octets en place. Le remplacement atomique ne garantit pas la durabilité en cas de coupure de courant : le script n’appelle pasfsync. La documentation de Tempfile de Ruby explique les permissions et le nettoyage explicite utilisés ici. -
Noms d’entrées non fiables :
plain_basename?rejette tout élément listé par le serveur qui n’est pas un simple nom de fichier. Sans cela, un nom tel quea/../../escaped.txtpasse à la fois le contrôle des fichiers cachés etattributes.file?, puis sort deLOCAL_DIRune fois concaténé, lit un chemin situé hors deREMOTE_DIRet, si la suppression est activée, supprime un fichier que vous n’avez jamais demandé. -
Gestion des erreurs : Réessaie en cas de refus de connexion ou de délai de connexion dépassé, jusqu’à trois tentatives, en attendant une seconde puis deux secondes. Les erreurs de statut SFTP et les erreurs du système de fichiers local survenant lors d’un téléchargement individuel sont journalisées et ignorées, afin que l’entrée suivante puisse tout de même être importée. Une clé d’hôte inconnue ou modifiée, un échec d’authentification, l’échec du listage d’un répertoire et une connexion SSH interrompue mettent fin au processus. Corrigez la cause avant de redémarrer ; ne contournez pas les vérifications d’hôte.
-
Arrêt : Ctrl+C ou
SIGTERMdemande l’arrêt. Pendant l’attente entre deux interrogations, le script vérifie cette demande une fois par seconde. Pendant un transfert, il termine le fichier en cours et toute suppression activée, puis s’arrête avant l’entrée suivante. Le délai de connexion limite l’établissement initial de la connexion, et non le transfert complet ; un serveur bloqué peut donc retarder l’arrêt. Une exception ordinaire déclenche le nettoyage des fichiers temporaires, ce queSIGKILLou un plantage de la machine ne permettent pas. Après un arrêt forcé, supprimez les fichierssftp-download*restants uniquement pendant que l’importateur est arrêté. -
Nettoyage distant facultatif : Supprime du serveur distant les fichiers téléchargés avec succès pour éviter un traitement en double, mais uniquement lorsque
DELETE_AFTER_DOWNLOAD=true. La suppression cible un nom, et non la révision téléchargée ; elle dépend donc de la même discipline d’écriture unique que l’avertissement ci-dessus. Si vous la laissez désactivée, vous avez besoin d’un autre moyen d’éviter un nouveau traitement, par exemple un registre local des noms de fichiers importés.
Déploiement en production
L’exemple exécutable ci-dessus est un processus au premier plan. Avant de le placer sous supervision, décidez comment les consommateurs évitent les traitements en double, surveillez les erreurs par fichier ainsi que les sorties du processus, et prévoyez un stockage durable pour les téléchargements. N’exécutez qu’un seul importateur par destination. Les points suivants sont des considérations d’empaquetage, et non des configurations de déploiement complètes.
Utilisation de Systemd
L’identité du service a besoin d’un accès en lecture à la clé, au fichier des hôtes de confiance, au script et au bundle installé, ainsi que d’un accès en écriture à la destination. Utilisez le répertoire du script comme répertoire de travail et lancez-le via Bundler. Dimensionnez le délai de grâce d’arrêt en fonction de vos transferts ; une demande d’arrêt n’annule pas un téléchargement en cours. Un superviseur qui finit par tuer un importateur bloqué peut laisser un fichier temporaire ; la procédure de reprise doit donc en tenir compte.
Utilisation de Docker
Utilisez une image Ruby maintenue, installez le même bundle verrouillé et conservez les identifiants
hors de l’image. Montez la clé et le fichier des hôtes de confiance en lecture seule, et rendez le
répertoire de téléchargement persistant sur un volume. Assurez-vous que Ruby reçoit le signal
d’arrêt. Sous Linux, le délai de grâce d’arrêt par défaut de Docker n’est que de 10 secondes ;
au-delà, il envoie SIGKILL. Configurez le délai de grâce selon votre charge
de travail et testez un transfert actif, pas seulement un conteneur inactif. Consultez
le comportement d’arrêt de Docker.
Bonnes pratiques de sécurité
-
Vérification de la clé d’hôte :
- Conservez
verify_host_key: :alwaysafin qu’une clé d’hôte inconnue ou modifiée interrompe la connexion - Alimentez
known_hostsà partir d’une empreinte que vous avez confirmée par un canal distinct, et non à partir de ce que la première connexion présente - Livrez
known_hostsavec le déploiement (voir la variableSSH_KNOWN_HOSTSci-dessus) plutôt que de dépendre du répertoire personnel de l’utilisateur qui exécute le script
- Conservez
-
Gestion des clés SSH :
- Renouvelez régulièrement les clés SSH
- Le bundle épinglé inclut les gems facultatives nécessaires aux clés Ed25519 dans Net::SSH 7.3.0
- Limitez l’accès au fichier de clé privée au compte de l’importateur
-
Sécurité réseau :
- Limitez l’accès SFTP à des plages d’adresses IP spécifiques
- Utilisez des algorithmes de chiffrement et d’échange de clés robustes
- Surveillez les transferts bloqués ; le délai de connexion initial n’est pas une échéance de transfert
-
Accès aux fichiers :
- Appliquez des permissions minimales aux fichiers locaux comme distants
- Mettez en place des contrôles d’intégrité des fichiers
- Nettoyez correctement les fichiers temporaires
-
Surveillance :
- Configurez des alertes pour les téléchargements échoués et les problèmes de connexion
- Surveillez l’utilisation de l’espace disque
- Suivez les métriques de traitement
Conclusion
Ce script Ruby est un point de départ pour automatiser les importations de fichiers SFTP. Il couvre les cas qui posent problème en premier : la vérification de la clé d’hôte, les noms d’entrées contrôlés par le serveur, une écriture locale soit complète soit absente, et un échec qui ne doit pas mettre fin à la boucle d’interrogation. La déduplication entre les redémarrages, les limites d’espace disque et les alertes sur un script d’interrogation devenu silencieux restent à votre charge.
Si vous utilisez déjà Transloadit, consultez la documentation sur l’importation SFTP (English).
