Transferencia segura de archivos con SFTP en Lua
SFTP transfiere archivos a través de SSH. socket.ftp de LuaSocket implementa FTP
sin cifrar, un protocolo diferente, y no puede establecer una conexión SFTP. Este artículo conserva
su URL publicada, pero reemplaza los ejemplos anteriores de FTP con un transporte SFTP real:
Lua-cURL respaldado por libcurl con soporte para SSH.
Consideraciones de seguridad
Obtén la clave pública de host del servidor a través de un canal autenticado y prepara un archivo
known_hosts privado antes de conectarte. No aceptes automáticamente una clave
desconocida. Usa una cuenta SSH y una clave dedicadas, con acceso de solo lectura y restringido por
el servidor al directorio de importación previsto.
El cliente rechaza los intentos de recorrer rutas en los nombres y nunca evalúa nombres de archivo como comandos. Esa comprobación léxica no puede impedir que el servidor resuelva un enlace simbólico fuera de un directorio. Exige un chroot u otra restricción equivalente del lado del servidor, sin enlaces a contenido sensible, antes de automatizar las importaciones. Usa un directorio de confianza del servidor cuyos nombres de entrada no contengan saltos de línea: el listado de libcurl que solo incluye nombres está delimitado por líneas y no puede representar esos nombres sin ambigüedad.
Configura el transporte SFTP
Usa LuaJIT 2.1, Lua-cURL 0.3.13-1,
LuaFileSystem 1.8.0-1 y una compilación de libcurl con mantenimiento vigente
que admita SFTP. El ejemplo se probó con libcurl 8.21.0 y libssh2
1.11.1. Un ejecutable curl del sistema puede usar
una biblioteca diferente de la del módulo de Lua; comprueba la biblioteca enlazada realmente con
Lua-cURL. LuaRocks debe usar los archivos de cabecera de tu intérprete LuaJIT.
luarocks --lua-version=5.1 install Lua-cURL 0.3.13-1
luarocks --lua-version=5.1 install luafilesystem 1.8.0-1
Si es necesario, proporciona el ajuste CURL_DIR de LuaRocks para la instalación
de libcurl con soporte para SSH. Consulta el proyecto Lua-cURL,
la verificación de claves de host de libcurl y los
listados de directorios que solo incluyen nombres.
Conéctate a un servidor SFTP
Guarda el siguiente módulo completo como sftp_import.lua. Los ajustes de conexión
provienen de una configuración de despliegue de confianza. root es un
directorio absoluto dentro de la cuenta restringida del servidor. Los archivos de clave pública y
privada deben pertenecer a la identidad de cliente dedicada de esa cuenta.
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, err = 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 file = assert(io.open(destination, "wb"))
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(destination)
return nil, "Could not complete download"
end
return destination
end
return M
Lista archivos y directorios
list_directory() solicita las entradas de un directorio SFTP, no la recuperación de un
archivo. Devuelve solo nombres, que pueden hacer referencia a archivos o directorios. No analiza la
salida de ls -l de Unix, no supone que cada entrada sea un archivo ni
interpreta un listado obtenido correctamente como permiso para descargarlo todo.
La política de restricción de nombres acepta nombres comunes con espacios, puntos, guiones bajos y
guiones. Rechaza barras, barras invertidas, caracteres de control, secuencias de escape con signo de
porcentaje y . o ... Amplía esa política
solo con las correspondientes pruebas de codificación y confinamiento de rutas. Si una entrada del
listado no está admitida, list_directory() devuelve nil y
Unsupported directory entry, sin devolver resultados parciales.
Descarga archivos del servidor SFTP
Guarda como import.lua este código que llama al módulo. 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
subdirectorio y su archivo heredan el valor restrictivo 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 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")
umask 077
luajit import.lua 'report September.csv' private-imports/job-001
Las descargas están limitadas a 64 MiB. Una entrada que sea un directorio,
un archivo inexistente, una discrepancia en la clave de host, una transferencia interrumpida o un
fallo por superar el límite de tamaño no deben notificarse como una descarga de archivo exitosa.
Los archivos locales parciales se eliminan después de los fallos de transferencia. El directorio
privado de la tarea se conserva para su inspección.
Automatiza las importaciones de archivos por SFTP
Llama al mismo módulo desde un programador de tareas con nombres de archivo explícitos y un directorio nuevo para cada tarea. Decide en el código de la aplicación qué entradas son aptas; los listados que solo incluyen nombres no contienen información sobre tipos de archivo ni 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 una publicación atómica del lado del servidor.
Manejo de errores y buenas prácticas
Bloquea la operación si las claves de host no coinciden. Reintenta solo después de clasificar el fallo operativo; un error de autenticación no es motivo para desactivar la verificación del host. Mantén las claves y los registros privados. El ejemplo usa llamadas a bibliotecas para las transferencias y la creación de directorios, por lo que los metacaracteres de shell no pueden convertir los nombres de archivo en comandos. Las restricciones de acceso del lado del servidor siguen siendo esenciales.
Alternativas 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 de descarga HTTPS autenticado.
Conclusión
Usa una biblioteca compatible con SSH, claves de host verificadas y reglas explícitas para los nombres remotos en las importaciones SFTP. El cliente FTP de LuaSocket no es una implementación de SFTP. Para importaciones gestionadas, consulta el 🤖 Robot /sftp/import de Transloadit.
