Transferencia segura de archivos con SFTP en Lua
Usa Lua-cURL con una libcurl compatible con SSH para listar un directorio SFTP y descargar un archivo
por su nombre. El ejemplo verifica la clave de host del servidor y guarda archivos binarios o vacíos
sin invocar un shell para las transferencias. El módulo
socket.ftp de LuaSocket implementa FTP
sin cifrado, un protocolo distinto de SFTP.
Consideraciones de seguridad
Empieza con una cuenta SFTP existente cuyo administrador te proporcione el nombre de host, el puerto, el nombre de usuario y el directorio tal como aparece dentro del chroot de la cuenta. El remitente debe colocar el archivo allí antes de que ejecutes el importador. Usa una cuenta dedicada de solo lectura con la clave pública de tu cliente registrada y una clave privada de cliente sin cifrar para este ejemplo que se ejecuta sin intervención.
Obtén la clave pública de host del servidor a través de un canal autenticado y prepara un archivo
known_hosts privado en formato OpenSSH. Un escaneo por sí solo no establece
confianza. La verificación de claves de host de libcurl rechaza
las claves desconocidas o que no coinciden cuando se configura ese archivo.
El cliente rechaza los nombres que permiten recorrer rutas y nunca evalúa nombres de archivo como comandos. Esa validación léxica no puede impedir que el servidor resuelva un enlace simbólico fuera de un directorio. Exige un chroot del lado del servidor que contenga únicamente los datos previstos; un chroot limita el acceso a todo su árbol, no solo al subdirectorio configurado. Usa un directorio de confianza en el servidor cuyos nombres de entrada no contengan saltos de línea: el listado de libcurl que incluye solo nombres está delimitado por líneas y no puede representar esos nombres de archivo de forma inequívoca.
Configuración del transporte SFTP
La configuración siguiente está dirigida a Linux con Bash, LuaJIT 2.1,
LuaRocks, un compilador de C, make, curl,
unzip y pkg-config. Instala primero los archivos de
desarrollo de LuaJIT y libcurl. pkg-config debe encontrar tanto
luajit como una libcurl compilada con libssh2 y soporte para SFTP; sus
bibliotecas compartidas deben estar disponibles para el cargador dinámico. Si usas un prefijo
personalizado para libcurl, configura PKG_CONFIG_PATH y el cargador de bibliotecas
para esa instalación antes de ejecutar la configuración.
Las versiones probadas son LuaJIT 2.1, LuaRocks
3.8 y 3.13, Lua-cURL
0.3.13-1, LuaFileSystem 1.8.0-1, libcurl
8.21.0 y 8.22.0, y libssh2
1.11.1.
Estas versiones documentan las configuraciones probadas; mantén las actualizaciones de seguridad de
tu distribución. La versión fijada de Lua-cURL es un binding antiguo; comprueba la compatibilidad si
la cambias. La configuración selecciona las rutas de desarrollo mediante
pkg-config en lugar de suponer que todos los archivos de cabecera se encuentran
directamente bajo /usr/include.
Guarda esto como setup.sh en el directorio padre donde quieras crear un nuevo
directorio lua-sftp. Instala el
paquete rock de Lua-cURL con la versión fijada y LuaFileSystem
en ese directorio en lugar de un árbol global de LuaRocks.
#!/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 el directorio lua-sftp ya existe, la configuración se detiene antes de la
instalación. Si la instalación falla, revisa el error y vuelve a ejecutar el script desde un
nuevo directorio padre después de corregir el requisito previo. El directorio de la instalación
fallida se conserva para que puedas examinarlo. La comprobación final informa qué libcurl usa
Lua-cURL y verifica el soporte para SFTP; un ejecutable curl del sistema
puede usar una biblioteca distinta.
Conexión a un servidor SFTP
Guarda el siguiente módulo completo como lua-sftp/sftp_import.lua. Los parámetros de conexión
proceden de una configuración de confianza. root es un directorio absoluto
visible a través de SFTP, como /exports, y no la ruta del sistema de archivos
del host del servidor. Los archivos de las claves pública y privada del cliente deben formar un par
coincidente.
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
Listado de archivos y directorios
list_directory() usa el
listado de directorios con solo nombres de libcurl. Devuelve
únicamente nombres; esos nombres pueden corresponder a archivos o directorios. No analiza la salida
de ls -l de Unix, ni supone que cada entrada es un archivo, ni interpreta
un listado exitoso como permiso para descargarlo todo.
La política de nombres restringidos acepta nombres ASCII con espacios, puntos, guiones bajos y
guiones. Rechaza barras, barras inversas, caracteres de control, secuencias de escape con porcentaje y
. o ... Amplía esa política solo si la
acompañas de pruebas correspondientes de representación de caracteres y de confinamiento dentro del
directorio. Una entrada del listado que no cumple la política hace que
list_directory() devuelva nil y
Unsupported directory entry, sin devolver resultados parciales.
Descarga de archivos del servidor SFTP
Guarda este programa que llama al módulo como lua-sftp/import.lua. Lista el directorio
configurado y descarga únicamente el archivo cuyo nombre exacto proporciona el operador. El
directorio padre de salida ya debe ser privado y pertenecer a la aplicación. El nuevo directorio
hijo y su archivo heredan la configuración restrictiva de umask que se
muestra a continuación.
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
)
Reemplaza los valores de conexión y las rutas de las claves por los que te proporcionaron para tu
cuenta. La salida de una ejecución exitosa lista los nombres del directorio y termina con
Download complete; el archivo queda entonces disponible en
lua-sftp/private-imports/job-001/report September.csv. Compara sus bytes o su suma de verificación con el original del
remitente para verificar el resultado. Un archivo de cero bytes es una descarga exitosa válida.
El listado está limitado a 1 MiB de nombres recibidos y cada descarga a 64 MiB. Cada operación tiene un límite de 10 segundos para establecer la conexión y un plazo total de 60 segundos, incluido el tiempo de conexión. Por tanto, una transferencia lenta puede fallar sin alcanzar el límite de tamaño. Un archivo cuyo acceso se deniega, un directorio, una conexión interrumpida o un límite excedido producen un resultado de fallo y la eliminación del archivo temporal. El nuevo directorio de la tarea se conserva; usa un nombre de tarea nuevo al reintentar. Un directorio de tarea existente se rechaza sin reemplazar sus archivos.
El nombre de archivo definitivo aparece solo después de que la transferencia finaliza correctamente
y se cierra el archivo. Si se interrumpe o se termina el proceso antes de la publicación, puede
quedar .download.part, pero ningún nombre de archivo definitivo. Examina o elimina
ese archivo temporal después de confirmar que la tarea se ha detenido; los consumidores deben usar
únicamente la ruta definitiva devuelta. Este paso de publicación no garantiza la durabilidad del
almacenamiento ante un fallo de la máquina.
Automatización de importaciones de archivos por SFTP
Llama al mismo módulo desde un programador de tareas usando nombres de archivo explícitos y un nuevo directorio de tarea. Decide qué entradas cumplen los requisitos en el código de la aplicación; los listados que incluyen solo nombres no contienen información sobre el tipo de archivo ni sobre los enlaces simbólicos. El listado y la descarga son operaciones separadas, por lo que el servidor puede cambiar un archivo entre ambas. Coordina con el remitente el uso de archivos inmutables o la publicación atómica del lado del servidor.
Manejo de errores y buenas prácticas
El programa que llama al módulo termina con un código de salida distinto de cero si se produce un
fallo. Ante fallos de autenticación, revisa la configuración de la cuenta y las claves de cliente
registradas; resuelve cualquier cambio de clave de host con el administrador antes de actualizar
known_hosts. Ante fallos de escritura local, revisa el espacio libre y los
permisos del directorio padre privado de salida. Mantén las claves y los registros privados, y
reintenta las descargas en un nuevo directorio de tarea.
Enfoques alternativos para transferencias seguras
El cliente sftp de OpenSSH es otra opción cuando se invoca mediante una
API que acepta un array de argumentos. No interpoles un nombre de archivo en
os.execute() ni en io.popen(). HTTPS es una opción distinta
solo cuando el origen ofrece un servicio autenticado de descarga mediante HTTPS.
Para importaciones gestionadas, consulta el 🤖 Robot /sftp/import de Transloadit.
