singe/assets/Net.singe
2026-09-13 23:48:15 -05:00

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