Transfert sécurisé de fichiers avec SFTP en Lua
Utilisez Lua-cURL avec une libcurl prenant en charge SSH pour lister un répertoire SFTP et
télécharger un fichier désigné par son nom. L’exemple vérifie la clé d’hôte du serveur et enregistre
les fichiers binaires ou vides sans invoquer de shell pour les transferts. Le module
socket.ftp de LuaSocket implémente FTP en clair,
un protocole différent de SFTP.
Considérations de sécurité
Commencez avec un compte SFTP existant dont l’administrateur vous fournit le nom d’hôte, le port, le nom d’utilisateur et le répertoire tel qu’il apparaît dans le chroot du compte. L’expéditeur doit y déposer le fichier avant que vous exécutiez l’importateur. Utilisez un compte dédié en lecture seule avec la clé publique de votre client enregistrée, ainsi qu’une clé privée de client non chiffrée pour cet exemple sans intervention humaine.
Obtenez la clé publique d’hôte du serveur par un canal authentifié et préparez un fichier
known_hosts privé au format OpenSSH. Un simple scan ne suffit pas à établir la
confiance. La vérification de la clé d’hôte de libcurl rejette les clés
inconnues ou non concordantes lorsque ce fichier est configuré.
Le client rejette les noms permettant une traversée de répertoires et n’évalue jamais les noms de fichiers comme des commandes. Cette vérification lexicale ne peut pas empêcher le serveur de résoudre un lien symbolique vers une cible située hors d’un répertoire. Exigez un chroot côté serveur contenant uniquement les données prévues ; un chroot limite l’accès à toute son arborescence, et pas seulement au sous-répertoire configuré. Utilisez un répertoire serveur de confiance dont les noms d’entrées ne contiennent aucun saut de ligne : la liste de noms seuls fournie par libcurl utilise les sauts de ligne comme séparateurs et ne peut pas représenter ces noms de fichiers sans ambiguïté.
Configurer le transport SFTP
La configuration ci-dessous cible Linux avec Bash, LuaJIT 2.1, LuaRocks, un compilateur C, make, curl,
unzip et pkg-config. Installez d’abord les fichiers de développement de LuaJIT et de libcurl. pkg-config doit trouver
à la fois luajit et une libcurl compilée avec libssh2 et la prise en charge
de SFTP ; leurs bibliothèques partagées doivent être accessibles au chargeur dynamique. Pour un
préfixe d’installation personnalisé de libcurl, configurez PKG_CONFIG_PATH et le
chargeur de bibliothèques pour cette installation avant de lancer la configuration.
Les versions testées sont LuaJIT 2.1, LuaRocks 3.8 et 3.13, Lua-cURL 0.3.13-1,
LuaFileSystem 1.8.0-1, libcurl 8.21.0 et 8.22.0, ainsi que libssh2 1.11.1.
Ces versions décrivent les configurations testées ; conservez les mises à jour de sécurité de
votre distribution. La version fixée de Lua-cURL est une ancienne couche d’intégration ;
vérifiez la compatibilité si vous la changez. La configuration sélectionne les chemins de
développement via pkg-config au lieu de supposer que tous les fichiers d’en-tête
se trouvent directement sous /usr/include.
Enregistrez ce script sous le nom setup.sh dans le répertoire parent où vous
souhaitez créer un répertoire lua-sftp. Il installe le
rock Lua-cURL à la version fixée et LuaFileSystem
dans ce répertoire plutôt que dans une arborescence LuaRocks globale.
#!/usr/bin/env bash
set -euo pipefail
unset LUA_PATH LUA_CPATH
for tool in luajit luarocks cc make curl unzip pkg-config; do
command -v "$tool" >/dev/null || { printf 'Missing prerequisite: %s\n' "$tool" >&2; exit 1; }
done
pkg-config --print-errors --exists luajit libcurl
lua_binary=$(command -v luajit)
c_compiler=$(command -v cc)
lua_include=$(pkg-config --variable=includedir luajit)
curl_include=$(pkg-config --variable=includedir libcurl)
curl_library=$(pkg-config --variable=libdir libcurl)
umask 077
mkdir lua-sftp
cd lua-sftp
curl -fsSLo lua-curl-0.3.13-1.src.rock https://luarocks.org/lua-curl-0.3.13-1.src.rock
curl -fsSLo luafilesystem-1.8.0-1.src.rock https://luarocks.org/luafilesystem-1.8.0-1.src.rock
luarocks --lua-version=5.1 --tree="$PWD/rocks" install \
lua-curl-0.3.13-1.src.rock \
LUA="$lua_binary" LUA_INCDIR="$lua_include" CC="$c_compiler" \
CURL_INCDIR="$curl_include" CURL_LIBDIR="$curl_library"
luarocks --lua-version=5.1 --tree="$PWD/rocks" install \
luafilesystem-1.8.0-1.src.rock \
LUA="$lua_binary" LUA_INCDIR="$lua_include" CC="$c_compiler"
mkdir private-imports
LUA_PATH='./rocks/share/lua/5.1/?.lua;./rocks/share/lua/5.1/?/init.lua' \
LUA_CPATH='./rocks/lib/lua/5.1/?.so' \
luajit -e 'local c=require("lcurl.safe"); print(c.version()); assert(c.version_info("protocols").SFTP, "libcurl lacks SFTP support"); assert(c.version():find("libssh2/", 1, true), "Use libcurl built with libssh2")'
bash setup.sh
Si un répertoire lua-sftp existe déjà, la configuration s’arrête avant
l’installation. Si l’installation échoue, examinez l’erreur et relancez le script depuis un nouveau
répertoire parent après avoir corrigé le prérequis. Le répertoire de l’installation échouée reste
disponible pour inspection. Le test final indique la libcurl utilisée par Lua-cURL et vérifie la
prise en charge de SFTP ; un exécutable système curl peut utiliser une
bibliothèque différente.
Se connecter à un serveur SFTP
Enregistrez le module complet suivant sous le nom lua-sftp/sftp_import.lua. Les paramètres de
connexion proviennent d’une configuration de confiance. root est un
répertoire absolu visible via SFTP, tel que /exports, et non le chemin dans le
système de fichiers de l’hôte du serveur. Les fichiers de clés publique et privée du client doivent
former une paire correspondante.
local curl = require("lcurl.safe")
local lfs = require("lfs")
local M = {}
local function name_ok(name)
return type(name) == "string" and #name <= 255 and name ~= "." and name ~= ".."
and name:match("^[A-Za-z0-9][A-Za-z0-9._ -]*$") ~= nil
end
local function encode(path)
return (path:gsub("([^A-Za-z0-9/_.~-])", function(byte)
return string.format("%%%02X", byte:byte())
end))
end
function M.remote_path(root, name)
assert(type(root) == "string" and root:sub(1, 1) == "/", "Absolute remote root required")
assert(not root:find("//", 1, true), "Invalid remote root")
for part in root:gmatch("[^/]+") do assert(name_ok(part), "Invalid remote root component") end
if name ~= nil then assert(name_ok(name), "Unsafe remote name") end
local prefix = root:gsub("/+$", "") .. "/"
return prefix .. (name or "")
end
local function transfer(config, path, listing, sink)
assert(config.host:match("^[A-Za-z0-9.-]+$"), "Invalid SSH hostname")
local port = config.port or 22
assert(type(port) == "number" and port == math.floor(port) and port > 0 and port < 65536, "Invalid SSH port")
local handle = assert(curl.easy({
url = "sftp://" .. config.host .. ":" .. port .. encode(path),
protocols = curl.PROTO_SFTP,
proxy = "",
username = assert(config.user),
ssh_auth_types = curl.SSH_AUTH_PUBLICKEY,
ssh_knownhosts = assert(config.known_hosts),
ssh_private_keyfile = assert(config.private_key),
ssh_public_keyfile = assert(config.public_key),
connecttimeout = 10,
timeout = 60,
dirlistonly = listing,
writefunction = sink
}))
local ok, result = pcall(function() return handle:perform() end)
handle:close()
if not ok or not result then return nil, "SFTP transfer failed" end
return true
end
function M.list_directory(config)
local chunks, length = {}, 0
local ok, err = transfer(config, M.remote_path(config.root), true, function(chunk)
length = length + #chunk
if length > 1024 * 1024 then return 0 end
chunks[#chunks + 1] = chunk
return #chunk
end)
if not ok then return nil, err end
local entries = {}
for name in table.concat(chunks):gmatch("([^\n]+)") do
if name ~= "." and name ~= ".." then
if not name_ok(name) then return nil, "Unsupported directory entry" end
entries[#entries + 1] = name
end
end
table.sort(entries)
return entries
end
function M.download(config, name, output_directory)
local remote = M.remote_path(config.root, name)
-- An exclusive directory prevents overwriting an existing file or following its symlink.
assert(lfs.mkdir(output_directory), "Output directory must be new, beneath a private parent")
local destination = output_directory .. "/" .. name
local temporary = output_directory .. "/.download.part"
local file = io.open(temporary, "wb")
if not file then return nil, "Could not create download" end
local size = 0
local called, ok, err = pcall(transfer, config, remote, false, function(chunk)
size = size + #chunk
if size > 64 * 1024 * 1024 then return 0 end
if not file:write(chunk) then return 0 end
return #chunk
end)
local closed = file:close()
if not called or not ok or not closed then
os.remove(temporary)
return nil, "Could not complete download"
end
if not os.rename(temporary, destination) then
os.remove(temporary)
return nil, "Could not publish download"
end
return destination
end
return M
Lister les fichiers et les répertoires
list_directory() utilise la
liste des noms seuls d’un répertoire fournie par libcurl. Il ne
renvoie que des noms ; ces noms peuvent désigner des fichiers ou des répertoires. Il n’analyse pas
la sortie Unix de ls -l, ne suppose pas que chaque entrée est un fichier et
ne considère pas qu’une liste obtenue avec succès autorise à tout télécharger.
La politique de restriction des noms accepte les noms ASCII contenant des espaces, des points,
des traits de soulignement et des traits d’union. Elle rejette les barres obliques, les barres
obliques inverses, les caractères de contrôle, les séquences d’échappement avec un signe de
pourcentage, ainsi que . ou ... N’élargissez
cette politique qu’avec des tests correspondants d’encodage et de confinement. Si une entrée de la
liste n’est pas prise en charge, list_directory() renvoie nil
et Unsupported directory entry, sans renvoyer de résultats partiels.
Télécharger des fichiers depuis le serveur SFTP
Enregistrez ce programme appelant sous le nom lua-sftp/import.lua. Il liste le répertoire
configuré et télécharge uniquement le fichier dont le nom exact est fourni par l’opérateur. Le
répertoire parent de sortie doit déjà être privé et appartenir à l’application. Le nouveau
sous-répertoire et son fichier héritent du umask restrictif indiqué
ci-dessous.
local sftp = require("sftp_import")
local config = {
host = assert(os.getenv("SFTP_HOST")),
port = tonumber(os.getenv("SFTP_PORT") or "22"),
user = assert(os.getenv("SFTP_USER")),
known_hosts = assert(os.getenv("SFTP_KNOWN_HOSTS")),
private_key = assert(os.getenv("SFTP_PRIVATE_KEY")),
public_key = assert(os.getenv("SFTP_PUBLIC_KEY")),
root = assert(os.getenv("SFTP_ROOT"))
}
local wanted = assert(arg[1], "remote filename required")
local entries, err = sftp.list_directory(config)
assert(entries, err)
local found = false
for _, name in ipairs(entries) do
print(name)
if name == wanted then found = true end
end
assert(found, "Requested entry is not in the directory")
local saved, failure = sftp.download(config, wanted, assert(arg[2], "new output directory required"))
assert(saved, failure)
print("Download complete")
(
cd lua-sftp || exit 1
umask 077
export SFTP_HOST='sftp.example.com' SFTP_PORT='22' SFTP_USER='import-reader'
export SFTP_ROOT='/exports'
export SFTP_KNOWN_HOSTS='/path/to/verified_known_hosts'
export SFTP_PRIVATE_KEY='/path/to/import-key' SFTP_PUBLIC_KEY='/path/to/import-key.pub'
LUA_PATH='./rocks/share/lua/5.1/?.lua;./rocks/share/lua/5.1/?/init.lua;./?.lua' \
LUA_CPATH='./rocks/lib/lua/5.1/?.so' \
luajit import.lua 'report September.csv' private-imports/job-001
)
Remplacez les valeurs de connexion et les chemins des clés par ceux fournis pour votre compte.
En cas de succès, la sortie liste les noms du répertoire et se termine par
Download complete ; le fichier est alors disponible à l’emplacement
lua-sftp/private-imports/job-001/report September.csv. Comparez ses octets ou sa somme de contrôle avec l’original de
l’expéditeur pour vérifier le résultat. Un fichier de zéro octet est un téléchargement réussi
valide.
La liste est limitée à 1 MiB de noms reçus, et chaque téléchargement à 64 MiB. Chaque opération a une limite de 10 secondes pour la connexion et un délai total de 60 secondes, temps de connexion compris. Un transfert lent peut donc échouer avant d’atteindre la limite de taille. Un accès refusé à un fichier, une cible qui est un répertoire, une connexion interrompue ou une limite dépassée entraîne un échec et la suppression du fichier temporaire. Le nouveau répertoire de tâche reste présent ; utilisez un nouveau nom de tâche lors d’une nouvelle tentative. Un répertoire de tâche existant est refusé sans remplacement de ses fichiers.
Le nom de fichier final n’apparaît qu’après la réussite du transfert et la fermeture du fichier.
L’interruption ou l’arrêt forcé du processus avant la publication peut laisser
.download.part, mais aucun nom de fichier final. Examinez ou supprimez ce fichier
temporaire après avoir confirmé l’arrêt de la tâche ; les consommateurs doivent utiliser uniquement
le chemin final renvoyé. Cette étape de publication ne garantit pas la durabilité du stockage en
cas de panne de la machine.
Automatiser les importations de fichiers SFTP
Appelez le même module depuis un planificateur en utilisant des noms de fichiers explicites et un nouveau répertoire de tâche. Décidez quelles entrées sont admissibles dans le code de l’application ; les listes de noms seuls ne contiennent aucune information sur le type de fichier ou les liens symboliques. La récupération de la liste et le téléchargement sont des opérations distinctes, donc le serveur peut modifier un fichier entre les deux. Convenez avec l’expéditeur d’utiliser des fichiers immuables ou une publication atomique côté serveur.
Gestion des erreurs et bonnes pratiques
Le programme appelant se termine avec un statut non nul en cas d’échec. Vérifiez les paramètres du
compte et les clés de client enregistrées en cas d’échec d’authentification ; résolvez tout
changement de clé d’hôte avec l’administrateur avant de mettre à jour
known_hosts. Pour les échecs d’écriture locale, vérifiez l’espace libre et les
autorisations du répertoire parent privé de sortie. Gardez les clés et les journaux privés, et
retentez les téléchargements dans un nouveau répertoire de tâche.
Autres approches pour les transferts sécurisés
Le client sftp d’OpenSSH est une autre option lorsqu’il est invoqué via une
API acceptant un tableau d’arguments. N’interpolez pas de nom de fichier dans
os.execute() ou io.popen(). HTTPS est une option distincte,
uniquement lorsque la source expose un service de téléchargement HTTPS authentifié.
Pour les importations gérées, consultez le 🤖 Robot /sftp/import (English) de Transloadit.
