Transferência segura de arquivos com SFTP em Lua
Use Lua-cURL com uma libcurl habilitada para SSH para listar um diretório SFTP e baixar um arquivo
especificado pelo nome. O exemplo verifica a chave de host do servidor e salva arquivos binários ou
vazios sem invocar um shell para as transferências. O
socket.ftp do LuaSocket implementa FTP em texto
simples, um protocolo diferente do SFTP.
Considerações de segurança
Comece com uma conta SFTP existente cujo administrador forneça o nome do host, a porta, o nome de usuário e o diretório conforme aparece dentro do chroot da conta. O remetente deve colocar o arquivo nesse diretório antes de você executar o importador. Use uma conta dedicada somente para leitura, com a chave pública do seu cliente cadastrada, e uma chave privada do cliente sem criptografia para este exemplo de execução sem supervisão.
Obtenha a chave pública de host do servidor por um canal autenticado e provisione um arquivo
known_hosts privado no formato OpenSSH. Uma varredura, por si só, não estabelece
confiança. A verificação da chave de host da libcurl rejeita chaves
desconhecidas ou divergentes quando esse arquivo está configurado.
O cliente rejeita tentativas de travessia de diretórios nos nomes e nunca interpreta nomes de arquivos como comandos. Essa validação lexical não pode impedir que o servidor resolva um link simbólico para fora de um diretório. Exija um chroot no servidor que contenha somente os dados pretendidos; um chroot limita o acesso à sua árvore inteira, não apenas ao subdiretório configurado. Use um diretório confiável no servidor cujos nomes de entradas não contenham quebras de linha: a listagem da libcurl que contém apenas nomes é delimitada por linhas e não consegue representar esses nomes de arquivos sem ambiguidade.
Configurar o transporte SFTP
A configuração abaixo se destina ao Linux com Bash, LuaJIT 2.1, LuaRocks,
um compilador C, make, curl,
unzip e pkg-config. Instale primeiro os arquivos de
desenvolvimento do LuaJIT e da libcurl. pkg-config deve encontrar tanto
luajit quanto uma libcurl compilada com suporte a libssh2 e SFTP; suas
bibliotecas compartilhadas devem estar disponíveis para o carregador dinâmico. Para um prefixo de
instalação personalizado da libcurl, configure PKG_CONFIG_PATH e o carregador de
bibliotecas para essa instalação antes de executar a configuração.
As versões testadas são LuaJIT 2.1, LuaRocks
3.8 e 3.13, Lua-cURL
0.3.13-1, LuaFileSystem 1.8.0-1, libcurl
8.21.0 e 8.22.0 e libssh2
1.11.1. Essas versões registram as configurações testadas; mantenha as
atualizações de segurança da sua distribuição. A versão fixada do binding Lua-cURL é antiga;
verifique a compatibilidade ao alterá-la. A configuração seleciona os caminhos de desenvolvimento
por meio de pkg-config, em vez de presumir que todos os cabeçalhos ficam
diretamente em /usr/include.
Salve o conteúdo abaixo como setup.sh no diretório pai onde você quer criar um
novo diretório lua-sftp. Ele instala a versão fixada do
rock Lua-cURL e o LuaFileSystem nesse diretório, em vez de usar uma
árvore global do 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
Se o diretório lua-sftp já existir, a configuração será interrompida antes da
instalação. Se a instalação falhar, examine o erro e execute o script novamente a partir de um novo
diretório pai após corrigir o pré-requisito. O diretório da tentativa que falhou permanece para
inspeção. A verificação final informa qual libcurl é usada pelo Lua-cURL e verifica o suporte a
SFTP; um executável curl do sistema pode usar uma biblioteca diferente.
Conectar a um servidor SFTP
Salve o módulo completo a seguir como lua-sftp/sftp_import.lua. As configurações de conexão
vêm de uma configuração confiável. root é um diretório absoluto visível
via SFTP, como /exports, e não o caminho no sistema de arquivos do host do
servidor. Os arquivos das chaves pública e privada do cliente devem formar um par correspondente.
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
Listar arquivos e diretórios
list_directory() usa a
listagem de diretório apenas com nomes da libcurl. Ela retorna
somente nomes; esses nomes podem se referir a arquivos ou diretórios. Ela não analisa a saída de
ls -l do Unix, não presume que todas as entradas sejam arquivos nem trata
uma listagem bem-sucedida como permissão para baixar tudo.
A política de restrição de nomes aceita nomes ASCII com espaços, pontos, sublinhados e hífens.
Ela rejeita barras, barras invertidas, caracteres de controle, sequências de escape com sinal de
porcentagem e . ou ... Amplie essa política
somente com testes correspondentes de codificação e de contenção no diretório. Uma entrada não
suportada na listagem faz list_directory() retornar nil e
Unsupported directory entry, sem retornar resultados parciais.
Baixar arquivos do servidor SFTP
Salve este programa que chama o módulo como lua-sftp/import.lua. Ele lista o diretório
configurado e baixa somente o nome de arquivo exato fornecido pelo operador. O diretório pai de
saída já deve ser privado e pertencer à aplicação. O novo subdiretório e seu arquivo herdam o
umask restritivo mostrado abaixo.
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
)
Substitua os valores de conexão e os caminhos das chaves pelos fornecidos para sua conta. A saída
em caso de sucesso lista os nomes do diretório e termina com Download complete; o
arquivo fica então disponível em lua-sftp/private-imports/job-001/report September.csv. Compare seus bytes ou sua soma de
verificação com o original do remetente para verificar o resultado. Um arquivo de zero bytes é um
download válido e bem-sucedido.
A listagem é limitada a 1 MiB de nomes recebidos, e cada download, a 64 MiB. Cada operação tem um limite de 10 segundos para a conexão e um prazo total de 60 segundos, incluindo o tempo de conexão. Uma transferência lenta pode, portanto, falhar antes de atingir o limite de tamanho. Um arquivo com acesso negado, um diretório, uma conexão interrompida ou um limite excedido resulta em falha e na remoção do arquivo temporário. O novo diretório da tarefa permanece; use um novo nome de tarefa ao tentar novamente. Um diretório de tarefa já existente é recusado sem substituir seus arquivos.
O nome final do arquivo aparece somente depois que a transferência é concluída com sucesso e o
arquivo é fechado. Interromper ou encerrar o processo antes da publicação pode deixar
.download.part, mas nenhum nome final de arquivo. Examine ou remova esse arquivo
temporário após confirmar que a tarefa parou; os consumidores devem usar somente o caminho final
retornado. Essa etapa de publicação não garante a durabilidade do armazenamento em caso de falha
da máquina.
Automatizar importações de arquivos SFTP
Chame o mesmo módulo a partir de um agendador, usando nomes de arquivos explícitos e um novo diretório de tarefa. Decida quais entradas são elegíveis no código da aplicação; listagens que contêm apenas nomes não incluem informações sobre tipos de arquivo ou links simbólicos. A listagem e o download são operações separadas, portanto o servidor pode alterar um arquivo entre elas. Combine com o remetente o uso de arquivos imutáveis ou de publicação atômica no servidor.
Tratamento de erros e boas práticas
O programa que chama o módulo termina com um status diferente de zero em caso de falha. Verifique
as configurações da conta e as chaves de cliente cadastradas em caso de falhas de autenticação;
resolva uma mudança de chave de host com o administrador antes de atualizar
known_hosts. Em caso de falhas de gravação local, verifique o espaço livre e as
permissões no diretório pai privado de saída. Mantenha as chaves e os logs privados e tente baixar
novamente em um novo diretório de tarefa.
Abordagens alternativas para transferências seguras
O cliente sftp do OpenSSH é outra opção quando invocado por uma API que
aceita um array de argumentos. Não interpole um nome de arquivo em os.execute()
ou io.popen(). HTTPS é uma opção separada somente quando a origem disponibiliza
um serviço de download HTTPS autenticado.
Para importações gerenciadas, consulte o Robot 🤖 /sftp/import da Transloadit.
