467 lines
15 KiB
Text
467 lines
15 KiB
Text
--[[
|
|
*
|
|
* Singe 3
|
|
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
|
|
*
|
|
* This program is free software; you can redistribute it and/or
|
|
* modify it under the terms of the GNU General Public License
|
|
* as published by the Free Software Foundation; either version 3
|
|
* of the License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program; if not, write to the Free Software
|
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
|
|
* 02110-1301, USA.
|
|
*
|
|
*
|
|
--]]
|
|
|
|
|
|
|
|
-- Talking to the master service without stopping the game.
|
|
--
|
|
-- Lua in Singe is not threaded, and ssl.https.request blocks until it finishes: a JSON call would
|
|
-- stutter the frame and a several-hundred-megabyte download would look like a hang. So every
|
|
-- request here is a state machine driven a slice at a time from netPump(), which the menu calls
|
|
-- once a frame. A caller hands in a function and gets called back when the answer arrives.
|
|
--
|
|
-- On trust. LuaSec's own https.lua ships with verify = "none", which encrypts the connection and
|
|
-- authenticates nobody -- anything on the path could read the password going up, the token coming
|
|
-- back, and alter the game being downloaded. Nothing here uses that module. A connection is
|
|
-- verified against a pinned public key: Singe ships to five platforms and LuaSec can read neither
|
|
-- the Windows certificate store nor the macOS keychain, and this client only ever talks to one
|
|
-- service, so pinning is both simpler and stronger than carrying a list of every public CA.
|
|
--
|
|
-- The pin is of the *public key*, not the certificate, so an ordinary renewal that keeps the key
|
|
-- does not lock out every cabinet; two pins are carried so a key can actually be rotated.
|
|
--
|
|
-- Without a pin the chain is verified against a CA list. Linux has one; Windows, macOS and the
|
|
-- handhelds have nothing LuaSec can read, so the engine extracts Mozilla's list as
|
|
-- Singe/cacerts.pem beside the other support files and that is used wherever the platform's own
|
|
-- is missing.
|
|
|
|
|
|
local socket = require("socket")
|
|
local ssl = require("ssl")
|
|
|
|
NET_TIMEOUT = 30000 -- ms with no progress at all before a request is abandoned
|
|
NET_CHUNK = 65536 -- bytes read per pump slice
|
|
NET_REDIRECT_MAX = 5
|
|
|
|
-- master.singeengine.com. Two pins: the one in use and its successor, so a key rotation is a
|
|
-- release that knows both, then a server that starts using the second. An empty list means the
|
|
-- host's own trust store is used instead, which is what a self-hosted server without a pin gets.
|
|
NET_PINS = {}
|
|
|
|
NET_CA_SYSTEM = "/etc/ssl/certs/ca-certificates.crt" -- Linux's list, when it is there,
|
|
NET_CA_SHIPPED = "Singe/cacerts.pem" -- and the one Singe carries otherwise.
|
|
|
|
local active = {}
|
|
local nextId = 1
|
|
|
|
|
|
-- The SHA-256 of a peer's public key, in the form the pins are written in.
|
|
local function keyPin(connection)
|
|
local cert = connection:getpeercertificate()
|
|
|
|
if not cert then
|
|
return nil
|
|
end
|
|
local pem = cert:pubkey()
|
|
if not pem then
|
|
return nil
|
|
end
|
|
return utilSha256(pem)
|
|
end
|
|
|
|
|
|
local function finish(request, ok, result)
|
|
active[request.id] = nil
|
|
if request.file then
|
|
request.file:close()
|
|
request.file = nil
|
|
end
|
|
if request.socket then
|
|
pcall(function() request.socket:close() end)
|
|
request.socket = nil
|
|
end
|
|
if request.onDone then
|
|
request.onDone(ok, result)
|
|
end
|
|
end
|
|
|
|
|
|
local function fail(request, message)
|
|
finish(request, false, { error = message, url = request.url })
|
|
end
|
|
|
|
|
|
-- scheme, host, port, path
|
|
local function parseUrl(url)
|
|
local scheme, rest = url:match("^(%a[%w+.-]*)://(.*)$")
|
|
|
|
if not scheme then
|
|
return nil
|
|
end
|
|
local hostPort, path = rest:match("^([^/]+)(.*)$")
|
|
if not hostPort then
|
|
return nil
|
|
end
|
|
local host, port = hostPort:match("^(.-):(%d+)$")
|
|
host = host or hostPort
|
|
port = tonumber(port) or ((scheme == "https") and 443 or 80)
|
|
return scheme, host, port, (path ~= "" and path or "/")
|
|
end
|
|
|
|
|
|
local function beginConnect(request)
|
|
local sock, err = socket.tcp()
|
|
|
|
if not sock then
|
|
return fail(request, "no socket: " .. tostring(err))
|
|
end
|
|
sock:settimeout(0)
|
|
request.socket = sock
|
|
-- A non-blocking connect answers "timeout" immediately and finishes in the background; the
|
|
-- socket becoming writable is what says it arrived.
|
|
local ok, connectErr = sock:connect(request.host, request.port)
|
|
if not ok and connectErr ~= "timeout" and connectErr ~= "already connected" then
|
|
return fail(request, "cannot reach " .. request.host .. ": " .. tostring(connectErr))
|
|
end
|
|
request.state = (request.scheme == "https") and "connecting" or "sending"
|
|
request.sent = 0
|
|
end
|
|
|
|
|
|
local function buildRequest(request)
|
|
local lines = {
|
|
string.format("%s %s HTTP/1.1", request.method, request.path),
|
|
"Host: " .. request.host,
|
|
"Connection: close",
|
|
"Accept-Encoding: identity",
|
|
"User-Agent: Singe/" .. SINGE_VERSION_STRING,
|
|
}
|
|
|
|
for name, value in pairs(request.headers or {}) do
|
|
lines[#lines + 1] = name .. ": " .. value
|
|
end
|
|
if request.body then
|
|
lines[#lines + 1] = "Content-Length: " .. #request.body
|
|
end
|
|
return table.concat(lines, "\r\n") .. "\r\n\r\n" .. (request.body or "")
|
|
end
|
|
|
|
|
|
-- The CA list a connection without a pin is verified against.
|
|
local function caFile()
|
|
local lfs = require("lfs")
|
|
|
|
if lfs.attributes(NET_CA_SYSTEM, "mode") == "file" then
|
|
return NET_CA_SYSTEM
|
|
end
|
|
|
|
return NET_CA_SHIPPED
|
|
end
|
|
|
|
|
|
local function startTls(request)
|
|
local params = {
|
|
mode = "client",
|
|
protocol = "any",
|
|
options = { "all", "no_sslv2", "no_sslv3", "no_tlsv1", "no_tlsv1_1" },
|
|
-- With a pin, the chain is checked against the key rather than a CA list, and the pin is
|
|
-- what decides. Without one, a CA list is asked -- and if it has nothing to say, the
|
|
-- request fails rather than proceeding unverified.
|
|
verify = (#NET_PINS > 0) and "none" or "peer",
|
|
cafile = caFile(),
|
|
}
|
|
local wrapped, err = ssl.wrap(request.socket, params)
|
|
|
|
if not wrapped then
|
|
return fail(request, "TLS setup failed: " .. tostring(err))
|
|
end
|
|
wrapped:settimeout(0)
|
|
if wrapped.sni then
|
|
wrapped:sni(request.host)
|
|
end
|
|
request.socket = wrapped
|
|
request.state = "handshake"
|
|
end
|
|
|
|
|
|
local function pumpHandshake(request)
|
|
local ok, err = request.socket:dohandshake()
|
|
|
|
if ok then
|
|
if #NET_PINS > 0 then
|
|
local seen = keyPin(request.socket)
|
|
local good = false
|
|
for _, pin in ipairs(NET_PINS) do
|
|
if seen == pin then
|
|
good = true
|
|
end
|
|
end
|
|
if not good then
|
|
-- The one failure that must never be shrugged off: a wrong key is either a
|
|
-- mis-deployed server or somebody in the middle, and both want a person.
|
|
return fail(request, "the server's key is not one this build trusts")
|
|
end
|
|
end
|
|
request.state = "sending"
|
|
return
|
|
end
|
|
if err ~= "wantread" and err ~= "wantwrite" and err ~= "timeout" then
|
|
fail(request, "TLS handshake failed: " .. tostring(err))
|
|
end
|
|
end
|
|
|
|
|
|
local function pumpSending(request)
|
|
if not request.outgoing then
|
|
request.outgoing = buildRequest(request)
|
|
end
|
|
local sent, err, partial = request.socket:send(request.outgoing, request.sent + 1)
|
|
|
|
if sent then
|
|
request.sent = sent
|
|
elseif err == "timeout" then
|
|
request.sent = partial or request.sent
|
|
else
|
|
return fail(request, "send failed: " .. tostring(err))
|
|
end
|
|
if request.sent >= #request.outgoing then
|
|
request.state = "reading"
|
|
end
|
|
end
|
|
|
|
|
|
-- Split the headers off once the blank line arrives, so the body can start going to disk rather
|
|
-- than accumulating in memory: a game is far too big to hold twice.
|
|
local function splitHead(request)
|
|
local at = request.buffer:find("\r\n\r\n", 1, true)
|
|
|
|
if not at then
|
|
return false
|
|
end
|
|
local head = request.buffer:sub(1, at - 1)
|
|
request.buffer = request.buffer:sub(at + 4)
|
|
request.status = tonumber(head:match("^HTTP/%d%.%d (%d%d%d)")) or 0
|
|
request.responseHeaders = {}
|
|
for name, value in head:gmatch("\r\n([^:\r\n]+):%s*([^\r\n]*)") do
|
|
request.responseHeaders[name:lower()] = value
|
|
end
|
|
request.length = tonumber(request.responseHeaders["content-length"])
|
|
request.got = 0
|
|
-- HTTP/1.1 may send the body in sized pieces instead of announcing a length; those are
|
|
-- unwrapped as they arrive and the last, empty, piece is the end of the body.
|
|
request.chunked = (request.responseHeaders["transfer-encoding"] or ""):lower():find("chunked", 1, true) ~= nil
|
|
request.chunkBuffer = ""
|
|
request.chunkLeft = nil
|
|
request.chunkDone = false
|
|
if request.toFile then
|
|
local file, err = io.open(request.toFile, "wb")
|
|
if not file then
|
|
fail(request, "cannot write " .. request.toFile .. ": " .. tostring(err))
|
|
return false
|
|
end
|
|
request.file = file
|
|
end
|
|
return true
|
|
end
|
|
|
|
|
|
local function consume(request, data)
|
|
request.got = request.got + #data
|
|
if request.file then
|
|
request.file:write(data)
|
|
else
|
|
request.parts[#request.parts + 1] = data
|
|
end
|
|
if request.onProgress then
|
|
request.onProgress(request.got, request.length)
|
|
end
|
|
end
|
|
|
|
|
|
-- Body bytes arriving under chunked transfer encoding: a hexadecimal size on a line, that many
|
|
-- bytes, a blank line, again until a size of zero. Whatever is left over waits for the next read.
|
|
local function consumeChunked(request, data)
|
|
request.chunkBuffer = request.chunkBuffer .. data
|
|
while not request.chunkDone do
|
|
if request.chunkLeft == nil then
|
|
local at = request.chunkBuffer:find("\r\n", 1, true)
|
|
if not at then
|
|
return
|
|
end
|
|
local size = tonumber(request.chunkBuffer:sub(1, at - 1):match("^%x+"), 16)
|
|
request.chunkBuffer = request.chunkBuffer:sub(at + 2)
|
|
if size == nil then
|
|
return fail(request, "the server sent a chunk with no size")
|
|
end
|
|
if size == 0 then
|
|
request.chunkDone = true
|
|
return
|
|
end
|
|
request.chunkLeft = size
|
|
elseif request.chunkLeft > 0 then
|
|
local take = request.chunkBuffer:sub(1, request.chunkLeft)
|
|
if #take == 0 then
|
|
return
|
|
end
|
|
request.chunkBuffer = request.chunkBuffer:sub(#take + 1)
|
|
request.chunkLeft = request.chunkLeft - #take
|
|
consume(request, take)
|
|
else
|
|
-- The blank line after a chunk's bytes.
|
|
if #request.chunkBuffer < 2 then
|
|
return
|
|
end
|
|
request.chunkBuffer = request.chunkBuffer:sub(3)
|
|
request.chunkLeft = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
local function complete(request)
|
|
local body = request.file and "" or table.concat(request.parts)
|
|
|
|
-- A redirect is followed only for a plain GET, and only within the same scheme and host: the
|
|
-- point of a pin is lost if a 302 can walk the client somewhere else.
|
|
local location = request.responseHeaders["location"]
|
|
if location and request.status >= 300 and request.status < 400 and request.redirects < NET_REDIRECT_MAX then
|
|
local scheme, host = parseUrl(location)
|
|
if scheme == request.scheme and host == request.host then
|
|
request.redirects = request.redirects + 1
|
|
request.url = location
|
|
local _, _, port, path = parseUrl(location)
|
|
request.port, request.path = port, path
|
|
request.buffer, request.parts, request.outgoing, request.sent = "", {}, nil, 0
|
|
request.chunked, request.chunkBuffer, request.chunkLeft, request.chunkDone = false, "", nil, false
|
|
if request.socket then pcall(function() request.socket:close() end) end
|
|
request.socket = nil
|
|
return beginConnect(request)
|
|
end
|
|
end
|
|
finish(request, true, { status = request.status, body = body, headers = request.responseHeaders,
|
|
bytes = request.got, file = request.toFile })
|
|
end
|
|
|
|
|
|
local function pumpReading(request)
|
|
local data, err, partial = request.socket:receive(NET_CHUNK)
|
|
local got = data or partial
|
|
|
|
if got and #got > 0 then
|
|
request.lastProgress = singeGetTicks()
|
|
if not request.responseHeaders then
|
|
request.buffer = request.buffer .. got
|
|
if splitHead(request) and #request.buffer > 0 then
|
|
local rest = request.buffer
|
|
request.buffer = ""
|
|
if request.chunked then
|
|
consumeChunked(request, rest)
|
|
else
|
|
consume(request, rest)
|
|
end
|
|
end
|
|
elseif request.chunked then
|
|
consumeChunked(request, got)
|
|
else
|
|
consume(request, got)
|
|
end
|
|
end
|
|
if not active[request.id] then
|
|
return -- A bad chunk failed the request from inside consumeChunked.
|
|
end
|
|
if request.responseHeaders and ((request.length and request.got >= request.length) or request.chunkDone) then
|
|
return complete(request)
|
|
end
|
|
if err == "closed" then
|
|
if request.responseHeaders then
|
|
return complete(request)
|
|
end
|
|
return fail(request, "the server closed before answering")
|
|
end
|
|
if err and err ~= "timeout" and err ~= "wantread" then
|
|
fail(request, "read failed: " .. tostring(err))
|
|
end
|
|
end
|
|
|
|
|
|
-- One slice of work for every request in flight. Cheap when nothing is happening.
|
|
--
|
|
-- The list is snapshotted first because a request that finishes calls back into the caller, and the
|
|
-- caller very reasonably starts another one from there -- a download that reports itself installed,
|
|
-- say. Adding to a table while pairs() walks it is not allowed, and the failure ("invalid key to
|
|
-- 'next'") names neither the table nor the callback that did it.
|
|
function netPump()
|
|
local running = {}
|
|
|
|
for id in pairs(active) do
|
|
running[#running + 1] = id
|
|
end
|
|
for _, id in ipairs(running) do
|
|
local request = active[id]
|
|
if request then
|
|
if singeGetTicks() - request.lastProgress > NET_TIMEOUT then
|
|
fail(request, "the server stopped responding")
|
|
elseif request.state == "connecting" then
|
|
-- Writable means the connect finished; LuaSec is wrapped around it only then.
|
|
local _, writable = socket.select(nil, { request.socket }, 0)
|
|
if writable and #writable > 0 then
|
|
request.lastProgress = singeGetTicks()
|
|
startTls(request)
|
|
end
|
|
elseif request.state == "handshake" then
|
|
pumpHandshake(request)
|
|
elseif request.state == "sending" then
|
|
pumpSending(request)
|
|
elseif request.state == "reading" then
|
|
pumpReading(request)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
-- Start a request. opts: url, method, headers, body, toFile, onProgress. onDone(ok, result) is
|
|
-- called once, with result.status/body/headers on success or result.error on failure.
|
|
function netRequest(opts, onDone)
|
|
local scheme, host, port, path = parseUrl(opts.url)
|
|
|
|
if not scheme or (scheme ~= "http" and scheme ~= "https") then
|
|
onDone(false, { error = "not a URL this understands: " .. tostring(opts.url) })
|
|
return nil
|
|
end
|
|
local request = {
|
|
id = nextId, url = opts.url, scheme = scheme, host = host, port = port, path = path,
|
|
method = opts.method or "GET", headers = opts.headers, body = opts.body,
|
|
toFile = opts.toFile, onProgress = opts.onProgress, onDone = onDone,
|
|
buffer = "", parts = {}, redirects = 0, lastProgress = singeGetTicks(),
|
|
}
|
|
nextId = nextId + 1
|
|
active[request.id] = request
|
|
beginConnect(request)
|
|
return request.id
|
|
end
|
|
|
|
|
|
function netCancel(id)
|
|
local request = active[id]
|
|
|
|
if request then
|
|
request.onDone = nil
|
|
finish(request, false, { error = "cancelled" })
|
|
end
|
|
end
|
|
|
|
|
|
function netBusy()
|
|
return next(active) ~= nil
|
|
end
|