Exportar archivos a Amazon S3 en Lua con LuaSocket
LuaSocket proporciona primitivas de red y pausas de duración fraccionaria, pero por sí solo no
implementa la autenticación de AWS ni la validación de certificados HTTPS. Este ejemplo usa la FFI
de LuaJIT para llamar al transporte SigV4 de libcurl, que recibe mantenimiento. LuaSocket proporciona
la espera entre reintentos. Este enfoque sustituye el constructor anterior
lua-resty-aws, que no se había probado, y la implementación manual de la firma.
Instalación y configuración
Usa LuaJIT 2.1, LuaSocket 3.1.0-1 y una compilación de libcurl con soporte vigente que incluya HTTPS y AWS SigV4.
El ejemplo se probó con libcurl 8.21.0. El ejemplo usa opciones disponibles desde libcurl
7.85.0; usa una versión con los parches actuales, no esa versión mínima histórica.
Instala libcurl y LuaJIT mediante el gestor de paquetes de tu sistema y luego instala LuaSocket para
el mismo intérprete de LuaJIT:
luarocks --lua-version=5.1 install luasocket 3.1.0-1
LuaRocks debe estar configurado para los archivos de cabecera y el intérprete de LuaJIT. Establece
LIBCURL_PATH en la ruta absoluta de confianza de tu biblioteca compartida de libcurl
si no está en la ruta habitual del cargador dinámico. Este ejemplo está pensado para macOS y Linux.
No requiere OpenResty ni LuaSec.
Configuración de credenciales de AWS
Usa credenciales autorizadas para guardar objetos en el bucket y el prefijo previstos. Da preferencia
a las credenciales temporales y proporciona las tres variables: AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY y AWS_SESSION_TOKEN. Las credenciales de larga duración
omiten el token de sesión. Obtén estas credenciales mediante la gestión de secretos de tu despliegue;
no las incluyas en el código fuente.
Establece AWS_REGION y S3_BUCKET en la región y el nombre reales
del bucket. El endpoint predeterminado que aparece a continuación admite buckets convencionales de
las regiones comerciales de AWS cuyos nombres contienen letras minúsculas, dígitos y guiones.
Otras particiones, los nombres con puntos, los puntos de acceso y los buckets de directorio necesitan
su propia configuración de endpoint verificada. Los permisos de IAM y el comportamiento real del
bucket requieren validación en un entorno real.
Uso de un transporte de firma verificado
CURLOPT_AWS_SIGV4 de libcurl firma la solicitud.
El token de sesión se envía como x-amz-security-token y se incluye en los encabezados
firmados. Las claves se codifican como bytes UTF-8 conservando las barras que sirven de separadores.
El módulo rechaza los componentes de ruta independientes . y
.. porque el modo SigV4 de libcurl no admite PATH_AS_IS;
normalizar esos componentes de forma silenciosa cambiaría la clave del objeto. El firmante calcula
el hash de los bytes exactos de POSTFIELDS, aunque el método HTTP sea
PUT. Esto evita mantener una implementación criptográfica independiente
en el tutorial.
Subida desde LuaJIT
Guarda este módulo como s3_upload.lua. Limita deliberadamente cada objeto a
16 MiB en memoria; las subidas de mayor tamaño necesitan una implementación
de streaming o de carga multiparte verificada por separado. endpoint y
cafile son ajustes de despliegue opcionales y de confianza, útiles para probar
con un recurso de prueba HTTPS. Nunca los aceptes desde una solicitud sin autenticar.
local ffi = require("ffi")
local socket = require("socket")
ffi.cdef[[
typedef void CURL;
struct curl_slist { char *data; struct curl_slist *next; };
int curl_global_init(long flags);
CURL *curl_easy_init(void);
void curl_easy_cleanup(CURL *handle);
int curl_easy_setopt(CURL *handle, int option, ...);
int curl_easy_perform(CURL *handle);
int curl_easy_getinfo(CURL *handle, int info, ...);
struct curl_slist *curl_slist_append(struct curl_slist *list, const char *value);
void curl_slist_free_all(struct curl_slist *list);
]]
local curl = ffi.load(os.getenv("LIBCURL_PATH") or "curl")
assert(curl.curl_global_init(3) == 0, "Cannot initialize libcurl")
-- Stable option identifiers from libcurl's public curl.h ABI.
local O = { URL=10002, PROXY=10004, WRITEFUNCTION=20011, POSTFIELDS=10015,
HTTPHEADER=10023, CUSTOMREQUEST=10036, SSL_VERIFYPEER=64, CAINFO=10065,
POSTFIELDSIZE=60, SSL_VERIFYHOST=81, CONNECTTIMEOUT=78, TIMEOUT=13,
USERNAME=10173, PASSWORD=10174, AWS_SIGV4=10305,
PROTOCOLS_STR=10318, FOLLOWLOCATION=52 }
local M = {}
function M.encode_key(key)
assert(type(key) == "string" and #key > 0 and #key <= 1024, "Invalid object key")
for part in key:gmatch("[^/]+") do
assert(part ~= "." and part ~= "..", "Dot path components are unsupported")
end
return (key:gsub("([^A-Za-z0-9/_.~-])", function(byte)
return string.format("%%%02X", byte:byte())
end))
end
local function header(value)
assert(type(value) == "string" and #value > 0 and not value:find("[%c]"), "Invalid header value")
return value
end
local function request(config, key, body, content_type)
local handle = curl.curl_easy_init()
assert(handle ~= nil, "Cannot allocate curl handle")
local headers = nil
local function append_header(value)
local updated = curl.curl_slist_append(headers, value)
assert(updated ~= nil, "Cannot allocate curl header")
headers = updated
end
local received = 0
local writer = ffi.cast("size_t (*)(char *, size_t, size_t, void *)", function(_, size, count)
local length = tonumber(size * count)
received = received + length
if received > 1024 * 1024 then return 0 end
return length
end)
local function option(name, value)
if type(value) == "number" then value = ffi.new("long", value) end
assert(curl.curl_easy_setopt(handle, O[name], value) == 0, "Unsupported curl option: " .. name)
end
local ok, result = pcall(function()
local endpoint = config.endpoint or ("https://" .. config.bucket .. ".s3." .. config.region .. ".amazonaws.com")
assert(endpoint:match("^https://[A-Za-z0-9.-]+:?%d*$"), "Expected a trusted HTTPS origin")
option("URL", endpoint .. "/" .. M.encode_key(key))
option("PROTOCOLS_STR", "https")
option("PROXY", "")
option("FOLLOWLOCATION", 0)
option("SSL_VERIFYPEER", 1)
option("SSL_VERIFYHOST", 2)
if config.cafile then option("CAINFO", config.cafile) end
option("CONNECTTIMEOUT", 10)
option("TIMEOUT", 60)
option("USERNAME", header(config.access_key))
option("PASSWORD", header(config.secret_key))
option("AWS_SIGV4", "aws:amz:" .. config.region .. ":s3")
option("CUSTOMREQUEST", "PUT")
option("POSTFIELDSIZE", #body)
option("POSTFIELDS", body)
option("WRITEFUNCTION", writer)
for _, value in ipairs({"Content-Type: " .. header(content_type), "Expect:"}) do
append_header(value)
end
if config.session_token then
append_header("x-amz-security-token: " .. header(config.session_token))
end
option("HTTPHEADER", headers)
local code = curl.curl_easy_perform(handle)
local status = ffi.new("long[1]")
assert(curl.curl_easy_getinfo(handle, 0x200002, status) == 0, "Cannot read HTTP status")
return { transport=tonumber(code), status=tonumber(status[0]) }
end)
curl.curl_easy_cleanup(handle)
curl.curl_slist_free_all(headers)
writer:free()
if not ok then error(result) end
return result
end
-- libcurl calls a Lua callback; this call path must stay outside compiled FFI traces.
jit.off(request, true)
function M.retryable(result)
if result.transport ~= 0 then
return ({[6]=true, [7]=true, [18]=true, [28]=true, [55]=true, [56]=true})[result.transport] == true
end
return ({[408]=true, [429]=true, [500]=true, [502]=true, [503]=true, [504]=true})[result.status] == true
end
function M.upload(config, file_path, key, content_type)
assert(config.region and config.region:match("^[a-z0-9-]+$"), "Invalid region")
assert(config.bucket and #config.bucket >= 3 and #config.bucket <= 63
and config.bucket:match("^[a-z0-9][a-z0-9-]*[a-z0-9]$"), "Invalid bucket name")
local file = assert(io.open(file_path, "rb"))
local body, read_error = file:read(16 * 1024 * 1024 + 1)
file:close()
if read_error then error(read_error) end
body = body or ""
assert(#body <= 16 * 1024 * 1024, "Use multipart upload for larger files")
for attempt = 1, 4 do
local result = request(config, key, body, content_type)
if result.transport == 0 and result.status == 200 then return true end
if attempt == 4 or not M.retryable(result) then
return nil, "Upload failed (transport " .. result.transport .. ", HTTP " .. result.status .. ")"
end
socket.sleep(math.min(8, 0.25 * 2^(attempt - 1)) * (0.5 + math.random()))
end
end
return M
Guarda como upload.lua este programa que llama al módulo:
local s3 = require("s3_upload")
local ok, err = s3.upload({
access_key = os.getenv("AWS_ACCESS_KEY_ID"),
secret_key = os.getenv("AWS_SECRET_ACCESS_KEY"),
session_token = os.getenv("AWS_SESSION_TOKEN"),
region = os.getenv("AWS_REGION"),
bucket = os.getenv("S3_BUCKET")
}, assert(arg[1], "local file required"), assert(arg[2], "object key required"), "application/octet-stream")
assert(ok, err)
print("Uploaded")
luajit upload.lua report.pdf 'reports/September report.pdf'
Manejo de errores y reintentos
Solo HTTP 200 confirma esta operación PutObject.
Las redirecciones, los fallos de autenticación y otros errores HTTP permanentes detienen los intentos
de inmediato. Ciertos errores transitorios de transporte, la limitación de solicitudes y los errores
del servicio dan lugar a un máximo de cuatro intentos. Los fallos de validación de certificados no
se reintentan. La espera usa socket.sleep() con fracciones de segundo; nunca construye
un comando de shell ni da formato a una fracción con un marcador de posición para enteros.
Cada reintento firma una solicitud nueva con los mismos bytes y la misma clave. La pérdida de una
respuesta puede aun así crear varias versiones en un bucket con control de versiones o repetir
notificaciones. Concilia los resultados ambiguos cuando esos efectos sean relevantes. Este ejemplo
acotado no implementa la limitación adaptativa de solicitudes, el manejo de
Retry-After, la renovación automática de credenciales ni la entrega exactamente
una vez.
Manejo del tipo de contenido
El programa que llama al módulo usa application/octet-stream. Proporciona el tipo de contenido
conocido cuando corresponda, como application/pdf; una extensión de nombre de archivo
por sí sola no valida el contenido del archivo. El módulo rechaza los caracteres de control en los
valores de los encabezados y nunca devuelve el cuerpo de la respuesta del servicio como error.
Conclusión
Usa un transporte de firma que reciba mantenimiento y verifica las claves codificadas, las credenciales temporales, los fallos de TLS y las respuestas que no indiquen éxito con un endpoint HTTPS aislado. Las comprobaciones locales del protocolo no pueden demostrar la autorización de IAM, las políticas del bucket ni la disponibilidad de los objetos en AWS; completa esas comprobaciones por separado con un bucket de prueba autorizado.
