Sichere Dateiübertragung mit SFTP in Lua
Verwenden Sie Lua-cURL mit einem libcurl mit SSH-Unterstützung, um ein SFTP-Verzeichnis aufzulisten
und eine benannte Datei herunterzuladen. Das Beispiel prüft den Hostschlüssel des Servers und
speichert binäre oder leere Dateien, ohne für Übertragungen eine Shell aufzurufen. Das Modul
socket.ftp von LuaSocket implementiert
unverschlüsseltes FTP, ein anderes Protokoll als SFTP.
Sicherheitsaspekte
Beginnen Sie mit einem bestehenden SFTP-Konto, dessen Administrator Ihnen Hostname, Port, Benutzername und das Verzeichnis so bereitstellt, wie es innerhalb der chroot-Umgebung des Kontos erscheint. Der Absender muss die Datei dort ablegen, bevor Sie den Importer ausführen. Verwenden Sie ein dediziertes Konto mit ausschließlich Leserechten, bei dem der öffentliche Schlüssel Ihres Clients hinterlegt ist, sowie einen privaten, unverschlüsselten Clientschlüssel für dieses unbeaufsichtigte Beispiel.
Beziehen Sie den öffentlichen Hostschlüssel des Servers über einen authentifizierten Kanal und
stellen Sie eine private Datei known_hosts im OpenSSH-Format bereit. Ein Scan
allein schafft keine Vertrauensbasis. Die
Hostschlüsselprüfung von libcurl weist unbekannte oder
abweichende Schlüssel zurück, wenn diese Datei angegeben ist.
Der Client weist Pfadtraversierung in Namen zurück und wertet Dateinamen niemals als Befehle aus. Diese lexikalische Prüfung kann den Server nicht daran hindern, einen symbolischen Link außerhalb eines Verzeichnisses aufzulösen. Setzen Sie eine serverseitige chroot-Umgebung voraus, die nur die vorgesehenen Daten enthält; eine chroot-Umgebung begrenzt den Zugriff auf ihren gesamten Verzeichnisbaum, nicht nur auf das konfigurierte Unterverzeichnis. Verwenden Sie ein vertrauenswürdiges Serververzeichnis, dessen Eintragsnamen keine Zeilenumbrüche enthalten: Die reine Namensauflistung von libcurl ist zeilengetrennt und kann solche Dateinamen nicht eindeutig darstellen.
SFTP-Transport einrichten
Die folgende Einrichtung ist für Linux mit Bash, LuaJIT 2.1, LuaRocks,
einem C-Compiler, make, curl,
unzip und pkg-config vorgesehen. Installieren Sie zuerst
LuaJIT und die Entwicklungsdateien für libcurl. pkg-config muss sowohl
luajit als auch ein mit libssh2 und SFTP-Unterstützung kompiliertes libcurl
finden; ihre gemeinsam genutzten Bibliotheken müssen dem dynamischen Loader zur Verfügung stehen.
Bei einem benutzerdefinierten Installationspräfix für libcurl konfigurieren Sie
PKG_CONFIG_PATH und den Bibliotheksloader für diese Installation, bevor Sie die
Einrichtung ausführen.
Getestet wurden LuaJIT 2.1, LuaRocks 3.8 und
3.13, Lua-cURL 0.3.13-1, LuaFileSystem
1.8.0-1, libcurl 8.21.0 und
8.22.0 sowie libssh2 1.11.1.
Diese Versionen dokumentieren die getesteten Konfigurationen; behalten Sie die Sicherheitsupdates
Ihrer Distribution bei. Die festgelegte Version von Lua-cURL ist ein älteres Binding;
prüfen Sie die Kompatibilität, wenn Sie die Version ändern. Die Einrichtung wählt Entwicklungspfade
mithilfe von pkg-config, statt anzunehmen, dass jede Header-Datei direkt unter
/usr/include liegt.
Speichern Sie dies als setup.sh im übergeordneten Verzeichnis, in dem Sie ein
neues Verzeichnis lua-sftp anlegen möchten. Es installiert das
Lua-cURL-Rock in der festgelegten Version und LuaFileSystem
in dieses Verzeichnis statt in einen globalen LuaRocks-Verzeichnisbaum.
#!/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
Ein bestehendes Verzeichnis lua-sftp stoppt die Einrichtung vor der
Installation. Falls die Installation fehlschlägt, prüfen Sie den Fehler und führen Sie das Skript
nach Behebung der fehlenden Voraussetzung erneut aus einem neuen übergeordneten Verzeichnis aus.
Das Verzeichnis des fehlgeschlagenen Versuchs bleibt zur Untersuchung erhalten. Die abschließende
Prüfung meldet das von Lua-cURL verwendete libcurl und prüft die SFTP-Unterstützung; eine ausführbare
Systemdatei curl kann eine andere Bibliothek verwenden.
Verbindung zu einem SFTP-Server herstellen
Speichern Sie das folgende vollständige Modul als lua-sftp/sftp_import.lua. Die
Verbindungseinstellungen stammen aus einer vertrauenswürdigen Konfiguration.
root ist ein absolutes, über SFTP sichtbares Verzeichnis, etwa
/exports, und nicht der Pfad im Host-Dateisystem des Servers. Die Dateien mit
dem öffentlichen und privaten Clientschlüssel müssen ein zusammengehöriges Paar bilden.
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
Dateien und Verzeichnisse auflisten
list_directory() verwendet die
reine Namensauflistung eines Verzeichnisses von libcurl. Sie gibt
nur Namen zurück; diese können sich auf Dateien oder Verzeichnisse beziehen. Sie analysiert keine
Unix-Ausgabe von ls -l, setzt nicht voraus, dass jeder Eintrag eine Datei
ist, und betrachtet eine erfolgreiche Auflistung nicht als Berechtigung, alles herunterzuladen.
Die eingeschränkte Namensrichtlinie akzeptiert ASCII-Namen mit Leerzeichen, Punkten, Unterstrichen
und Bindestrichen. Sie weist Schrägstriche, umgekehrte Schrägstriche, Steuerzeichen,
Prozent-Escapesequenzen sowie . oder ..
zurück. Erweitern Sie diese Richtlinie nur mit passenden Tests zur Zeichenkodierung und zur
Einhaltung der Verzeichnisgrenzen. Bei einem nicht unterstützten Eintrag in der Auflistung gibt
list_directory() die Werte nil und
Unsupported directory entry zurück, ohne Teilergebnisse zurückzugeben.
Dateien vom SFTP-Server herunterladen
Speichern Sie diesen aufrufenden Code als lua-sftp/import.lua. Er listet das konfigurierte
Verzeichnis auf und lädt ausschließlich die Datei mit dem exakten Namen herunter, den der Betreiber
angegeben hat. Das übergeordnete Ausgabeverzeichnis muss bereits privat sein und der Anwendung
gehören. Das neue Unterverzeichnis und seine Datei übernehmen die restriktive Einstellung
umask, die unten gezeigt wird.
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
)
Ersetzen Sie die Verbindungswerte und Schlüsselpfade durch die Angaben für Ihr Konto. Bei Erfolg
listet die Ausgabe die Namen im Verzeichnis auf und endet mit Download complete;
die Datei ist dann unter lua-sftp/private-imports/job-001/report September.csv verfügbar. Vergleichen Sie ihre Bytes oder
Prüfsumme mit dem Original des Absenders, um das Ergebnis zu überprüfen. Eine Datei mit null Bytes
ist ein gültiger erfolgreicher Download.
Die Auflistung ist auf 1 MiB empfangener Namen begrenzt, jeder Download auf 64 MiB. Für jeden Vorgang gilt ein Verbindungslimit von 10 Sekunden und eine Gesamtfrist von 60 Sekunden, einschließlich der Verbindungszeit. Eine langsame Übertragung kann daher unterhalb des Größenlimits fehlschlagen. Ein verweigerter Dateizugriff, ein Verzeichnis, eine unterbrochene Verbindung oder ein überschrittenes Limit führt zu einem Fehler und entfernt die temporäre Datei. Das neue Job-Verzeichnis bleibt erhalten; verwenden Sie bei einem erneuten Versuch einen neuen Job-Namen. Ein bestehendes Job-Verzeichnis wird abgelehnt, ohne seine Dateien zu ersetzen.
Der endgültige Dateiname erscheint erst, nachdem die Übertragung erfolgreich abgeschlossen und
die Datei geschlossen wurde. Wenn der Prozess vor der Veröffentlichung unterbrochen oder beendet
wird, kann .download.part zurückbleiben, jedoch kein endgültiger Dateiname.
Prüfen oder entfernen Sie diese temporäre Datei, nachdem Sie bestätigt haben, dass der Job beendet
ist; nachgelagerte Anwendungen sollten nur den zurückgegebenen endgültigen Pfad verwenden.
Dieser Veröffentlichungsschritt garantiert keine dauerhafte Speicherung über einen Rechnerabsturz
hinweg.
SFTP-Dateiimporte automatisieren
Rufen Sie dasselbe Modul mit einem Scheduler auf und verwenden Sie explizite Dateinamen sowie ein neues Job-Verzeichnis. Entscheiden Sie im Anwendungscode, welche Einträge infrage kommen; reine Namensauflistungen enthalten keine Informationen zu Dateitypen oder symbolischen Links. Auflistung und Download sind getrennte Vorgänge, daher kann der Server eine Datei dazwischen ändern. Stimmen Sie mit dem Absender unveränderliche Dateien oder eine atomare serverseitige Veröffentlichung ab.
Fehlerbehandlung und bewährte Verfahren
Bei einem Fehler beendet sich der aufrufende Code mit einem Status ungleich null. Prüfen Sie bei
Authentifizierungsfehlern die Kontoeinstellungen und die hinterlegten Clientschlüssel; klären Sie
eine Änderung des Hostschlüssels mit dem Administrator, bevor Sie known_hosts
aktualisieren. Prüfen Sie bei lokalen Schreibfehlern den freien Speicherplatz und die Berechtigungen
im privaten übergeordneten Ausgabeverzeichnis. Halten Sie Schlüssel und Protokolle privat und
wiederholen Sie Downloads in einem neuen Job-Verzeichnis.
Alternative Ansätze für sichere Übertragungen
Der Client sftp von OpenSSH ist eine weitere Option, wenn er über eine API
aufgerufen wird, die ein Argument-Array akzeptiert. Interpolieren Sie keinen Dateinamen in
os.execute() oder io.popen(). HTTPS ist nur dann eine separate
Option, wenn die Quelle einen authentifizierten HTTPS-Downloaddienst bereitstellt.
Für verwaltete Importe nutzen Sie den 🤖 Robot /sftp/import von Transloadit.
