--[[ * * Singe 3 * Copyright (C) 2006-2026 Scott Duensing * * 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. 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 = {} 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 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, the host's own store is asked -- and if it has nothing to -- say, the request fails rather than proceeding unverified. verify = (#NET_PINS > 0) and "none" or "peer", capath = "/etc/ssl/certs", cafile = "/etc/ssl/certs/ca-certificates.crt", } 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 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 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 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 = "" consume(request, rest) end else consume(request, got) end end if request.responseHeaders and request.length and request.got >= request.length 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