DVX_GUI/server/dvxserver.lua
2026-10-20 17:24:15 -05:00

522 lines
22 KiB
Lua

-- The MIT License (MIT)
--
-- Copyright (C) 2026 Scott Duensing
--
-- Permission is hereby granted, free of charge, to any person obtaining a copy
-- of this software and associated documentation files (the "Software"), to
-- deal in the Software without restriction, including without limitation the
-- rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
-- sell copies of the Software, and to permit persons to whom the Software is
-- furnished to do so, subject to the following conditions:
--
-- The above copyright notice and this permission notice shall be included in
-- all copies or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
-- IN THE SOFTWARE.
-- dvxserver.lua -- the DVX package server: the catalogue of add-on packages that DVX's Software
-- program browses, installs, and updates from, and where publishers upload them.
--
-- calog dvxserver.lua -- dvx.conf (in this folder, beside ../../singemaster)
-- calog ../../singemaster/master.lua dvxserver.lua -- singe=../../singemaster master=master.conf dvx=dvx.conf
--
-- Plain HTTP on loopback, behind a reverse proxy (as singemaster: see its README). Built on
-- singemaster's shared modules (accounts, the log, mail, keys, the blob store, and service.lua's
-- route wrapper, account routes, and operator checks), so the two can run in one calog process.
--
-- Nothing a package says is taken on the uploader's word: the DVX package tool (dvxpkg check, the
-- code DVX installs with) checks every upload. A version is published only when the operator
-- approves it with the repository's signature, made on the operator's own computer
-- (tools/review.lua): the service holds no secret key, only the repository's public key, with which
-- it checks the signature it is given. DOS machines download slowly, so several contexts serve,
-- and one download does not hold up everyone else.
-- calogScript() is this file, so this folder, singemaster (../../singemaster), and calog (../../calog)
-- are found from wherever calog was started; singe=DIR and calog=DIR in calogArgs() say where the
-- other two are when they are elsewhere. calogArgs() is one list for every script in the run, so
-- with another service in the same process dvx=PATH says which configuration is whose; run alone,
-- the first plain argument is the configuration. These come before anything is loaded, so they
-- cannot come from singemaster's modules (master.lua has its own). A serving context is a task,
-- which no file started: it is given this folder as DVX_HERE (see the end of this file).
local function named(name)
for _, value in ipairs(calogArgs()) do
local found = value:match("^" .. name .. "=(.+)$")
if found then
return found
end
end
return nil
end
local function ownArg(name)
local found = named(name)
if found then
return found
end
for _, value in ipairs(calogArgs()) do
if not value:find("=", 1, true) then
return value
end
end
return nil
end
local HERE = DVX_HERE or (calogScript():match("^(.*)[/\\]") or ".")
local SINGE = named("singe") or (HERE .. "/../../singemaster")
local CALOG = named("calog") or (HERE .. "/../../calog")
local function need(path)
return load(fsRead(path), "@" .. path)()
end
local config = need(SINGE .. "/lib/config.lua")
local db = need(SINGE .. "/lib/db.lua")
local audit = need(SINGE .. "/lib/audit.lua")
local web = need(SINGE .. "/lib/web.lua")
local accounts = need(SINGE .. "/lib/accounts.lua")
local mail = need(SINGE .. "/lib/mail.lua")
local keys = need(SINGE .. "/lib/keys.lua")
local blobs = need(SINGE .. "/lib/blobs.lua")
local service = need(SINGE .. "/lib/service.lua")
local packages = need(HERE .. "/lib/packages.lua")
local httpd = need(CALOG .. "/examples/httpd.lua")
local smtp = need(CALOG .. "/examples/smtp.lua")
-- The keys dvx.conf must have, and the ones that are numbers
local REQUIRED = { "bindHost", "bindPort", "publicOrigin", "dataDir", "database", "pbkdf2Iterations", "mailFrom",
"dvxpkg", "repoKey", "serveContexts", "uploadMax" }
local NUMERIC = { bindPort = true, pbkdf2Iterations = true, smtpPort = true, serveContexts = true, uploadMax = true }
-- Every event the service writes (audit.lua refuses a route naming one that is not here)
local EVENTS = {
"account.register", "account.verify", "account.login", "account.logout", "account.read",
"account.recover", "account.reset", "account.sweepPending",
"catalogue.list", "package.get", "package.download", "package.signature", "updates.check",
"package.upload", "catalogue.mine",
"admin.pending", "admin.download", "admin.versionState", "version.approve", "version.reject",
"admin.packageState", "admin.log", "key.grant", "key.revoke",
"health", "service.start", "service.stop", "mail.sent", "mail.failed",
}
-- The service's own tables, after the shared ones (accounts, sessions, the log, rate limits, mail,
-- keys). A package is vendor.name; each version's bytes are a blob, stored once by SHA-256.
local SCHEMA = {
[[
CREATE TABLE blob (
id INTEGER PRIMARY KEY,
size INTEGER NOT NULL,
sha256 TEXT NOT NULL UNIQUE
)
]],
[[
CREATE TABLE package (
id TEXT PRIMARY KEY, -- vendor.name
ownerAccountId INTEGER REFERENCES account(id), -- NULL: the operator's (DVX's own)
state TEXT NOT NULL, -- published | withdrawn
createdAt INTEGER NOT NULL
)
]],
[[CREATE INDEX packageOwner ON package(ownerAccountId)]],
[[
CREATE TABLE packageVersion (
packageId TEXT NOT NULL REFERENCES package(id) ON DELETE CASCADE,
version TEXT NOT NULL, -- major.minor.patch
rank INTEGER NOT NULL, -- the version as one number, for ordering
name TEXT NOT NULL,
kind TEXT NOT NULL,
summary TEXT,
publisher TEXT,
author TEXT,
licence TEXT,
homepage TEXT,
dvx TEXT, -- the oldest DVX it runs on, or NULL
abi INTEGER, -- code: the ABI it was made for
manifest TEXT NOT NULL, -- PACKAGE.INI exactly as signed
signerKey TEXT, -- the publisher's key id (PACKAGE.SIG), or NULL
repoSig TEXT, -- the repository's detached signature, set on approval
blobId INTEGER NOT NULL REFERENCES blob(id),
state TEXT NOT NULL, -- pending | approved | rejected
note TEXT,
uploadedBy INTEGER REFERENCES account(id),
uploadedAt INTEGER NOT NULL,
reviewedAt INTEGER,
PRIMARY KEY (packageId, version)
)
]],
[[CREATE INDEX packageVersionState ON packageVersion(state, uploadedAt)]],
[[
CREATE TABLE packageDepend (
packageId TEXT NOT NULL,
version TEXT NOT NULL,
dependId TEXT NOT NULL,
minVersion TEXT NOT NULL,
PRIMARY KEY (packageId, version, dependId),
FOREIGN KEY (packageId, version) REFERENCES packageVersion(packageId, version) ON DELETE CASCADE
)
]],
}
local MIGRATIONS = { db.join(db.SHARED_SCHEMA, db.KEY_SCHEMA, SCHEMA) }
local CONFIG_KEY = "dvxserver.config" -- kv: the configuration's path, for the serving contexts
local LISTENER_KEY = "dvxserver.listener" -- kv: the listener the serving contexts accept on
local STOP_MS = 500 -- the longest a context waits (an accept, a pause) to see a stop
local RETRY_MS = 20 -- the pause after an accept that failed
local CHORE_SECONDS = 30
local PATH_ID_VERSION = "^([%w_.]+)/([%d.]+)$" -- <id>/<version> in a path
-- A serving context started by the main one (see the end of this file) is numbered, and finds the
-- configuration and the listener in kv; the main context reads its arguments and puts them there.
local worker = DVX_WORKER
local configPath = worker and kvGet(CONFIG_KEY) or (ownArg("dvx") or (HERE .. "/dvx.conf"))
local settings, configError = config.load(configPath, REQUIRED, NUMERIC)
if not settings then
calogPrint("dvxserver configuration:", configError)
calogExit(1)
end
fsMkdir(settings.dataDir)
local handle, schemaVersion = db.open(settings.database, MIGRATIONS)
if not handle then
calogPrint("dvxserver database:", schemaVersion)
calogExit(1)
end
blobs.TABLE = "blob"
local log = audit.new(handle, EVENTS)
local server = httpd.new()
local svc = service.new({ handle = handle, log = log, settings = settings, server = server, product = "DVX",
audit = audit, web = web, accounts = accounts, mail = mail, keys = keys, smtp = smtp })
-- The operator: the console token, or an account holding the admin key. Unlike svc:operator,
-- asking does not mark the request refused.
local function isOperator(req, account)
return svc:console(req) or (account ~= nil and keys.holds(handle, account.id, keys.ADMIN))
end
-- <id>/<version> at the end of a request's path, the version as the catalogue writes it ("2.0" is
-- 2.0.0), or nil.
local function idVersion(req)
local id, text = (req.rest or ""):match(PATH_ID_VERSION)
local version = text and packages.versionOf(text)
if not version then
return nil
end
return id, version
end
-- A version's bytes, resumable, as id-version.dpk: the reply, the bytes sent, and whether resumed.
local function sendVersion(req, id, version, row)
return web.fileReply(req, { path = blobs.path(settings.dataDir, row.sha256), size = row.size, sha256 = row.sha256 },
id .. "-" .. version .. ".dpk")
end
-- Run the package tool: its exit status and what it printed.
local function dvxpkg(args)
local argv = { settings.dvxpkg }
for _, a in ipairs(args) do
argv[#argv + 1] = a
end
local r = procRun(argv)
return r.exit, r.stdout or "", r.stderr or ""
end
-- What the package tool says about a refused file, without its "dvxpkg: " prefix.
local function toolReason(text)
return (tostring(text):gsub("^dvxpkg:%s*", ""):gsub("%s+$", ""))
end
svc:route("GET", "/v1/health", "health", function(req, context)
return web.json(200, { ok = true, service = "dvx-packages", schema = schemaVersion })
end)
svc:accountRoutes()
svc:keyRoutes()
svc:logRoute()
-- Every published package at its newest approved version. Anonymous: the catalogue is public.
svc:route("GET", "/v1/catalogue", "catalogue.list", function(req, context)
local list = packages.catalogue(handle)
context.detail = { count = #list }
return web.json(200, { packages = list, count = #list })
end)
-- /v1/package/<id> -- one package and its approved versions, newest first.
svc:route("GET", "/v1/package/", "package.get", function(req, context)
local id = (req.rest or ""):match("^([%w_.]+)$")
local versions = id and packages.versions(handle, id)
if not versions then
return web.fail(404, "noPackage", "no such package")
end
context.targetType = "package"
context.targetId = id
return web.json(200, { id = id, versions = versions })
end)
-- /v1/download/<id>/<version> -- the bytes of an approved version, resumable.
svc:route("GET", "/v1/download/", "package.download", function(req, context)
local id, version = idVersion(req)
local row = id and packages.servable(handle, id, version)
if not row then
return web.fail(404, "noVersion", "no such package or version")
end
context.targetType = "package"
context.targetId = id
context.targetVersion = version
local reply, sent, resumed = sendVersion(req, id, version, row)
context.detail = { size = row.size, sent = sent, resumed = resumed }
return reply
end)
-- /v1/signature/<id>/<version> -- the repository's signature of an approved version (a NAME.SIG
-- file), which DVX checks with the repository key it was installed with.
svc:route("GET", "/v1/signature/", "package.signature", function(req, context)
local id, version = idVersion(req)
local row = id and packages.servable(handle, id, version)
if not row or not row.repoSig then
return web.fail(404, "noSignature", "no such package or version")
end
context.targetType = "package"
context.targetId = id
context.targetVersion = version
return { status = 200, headers = { ["Content-Type"] = "text/plain" }, body = row.repoSig }
end)
-- What is newer than the packages a DVX has: {installed: [{id, version}]}. The list is the
-- client's own and nothing of it is kept; the answer names nothing private.
svc:route("POST", "/v1/updates", "updates.check", function(req, context)
local body, bad = web.body(req)
if not body then return bad end
if type(body.installed) ~= "table" then
return web.fail(400, "badRequest", "installed must be a list of {id, version}")
end
local newer, unknown = packages.updates(handle, body.installed)
context.detail = { installed = #body.installed, newer = #newer, unknown = #unknown, dvx = body.dvx, abi = body.abi }
return web.json(200, { updates = newer, unknown = unknown })
end)
-- An upload: a .DPK as the body, from a signed-in account (or the operator), taken in as a
-- pending version for review once the package tool has checked it.
svc:route("POST", "/v1/upload", "package.upload", function(req, context)
local account = svc:signedIn(req, context)
local operator = isOperator(req, account)
if not account and not operator then
context.result = "denied"
return web.fail(401, "noSession", "sign in first")
end
if not req.bodyFile or (req.bodySize or 0) == 0 then
return web.fail(400, "badRequest", "the body is the package (.dpk)")
end
local status, manifest, why = dvxpkg({ "check", req.bodyFile })
if status ~= 0 then
context.detail = { reason = toolReason(why) }
return web.fail(400, "badPackage", toolReason(why))
end
local _, info = dvxpkg({ "info", req.bodyFile })
local signer = info:match("signed by key (%x+)")
local blobId = blobs.put(handle, settings.dataDir, req.bodyFile)
local id, result, reason = packages.intake(handle, manifest, blobId, signer, (not operator) and account.id or nil)
if not id then
context.result = "denied"
context.detail = { reason = reason }
return web.fail(result, "refused", reason)
end
local version = result
context.targetType = "package"
context.targetId = id
context.targetVersion = version
context.description = string.format("%s uploaded %s %s for review", account and account.email or "the operator", id, version)
return web.json(202, { id = id, version = version, state = "pending" })
end, { spool = { dir = settings.dataDir .. "/work", max = settings.uploadMax } })
-- The uploader's own packages, every version with the review's word on it.
svc:route("GET", "/v1/mine", "catalogue.mine", function(req, context)
local account = svc:signedIn(req, context)
if not account then
context.result = "denied"
return web.fail(401, "noSession", "sign in first")
end
return web.json(200, { versions = packages.mine(handle, account.id) })
end)
-- Operator: the versions waiting for review.
svc:route("GET", "/v1/admin/pending", "admin.pending", function(req, context)
local ok, refusal = svc:operator(req, context)
if not ok then return refusal end
local rows = packages.pending(handle)
context.detail = { count = #rows }
return web.json(200, { pending = rows, count = #rows })
end)
-- Operator: /v1/admin/download/<id>/<version> -- any version's bytes, to review and sign.
svc:route("GET", "/v1/admin/download/", "admin.download", function(req, context)
local ok, refusal = svc:operator(req, context)
if not ok then return refusal end
local id, version = idVersion(req)
local row = id and packages.servable(handle, id, version, true)
if not row then
return web.fail(404, "noVersion", "no such package or version")
end
context.targetType = "package"
context.targetId = id
context.targetVersion = version
return (sendVersion(req, id, version, row))
end)
-- Operator: {package, version, state: approved or rejected, note, signature}. Approving takes the
-- repository's detached signature of the package (dvxpkg sign NAME.DPK REPO.SEC -d, on the
-- operator's own computer), which must check against the repository's public key.
svc:route("POST", "/v1/admin/version/state", "admin.versionState", function(req, context)
local ok, refusal = svc:operator(req, context)
if not ok then return refusal end
local body, bad = web.body(req)
if not body then return bad end
local id = web.field(body, "package")
local version = packages.versionOf(web.field(body, "version"))
local state = web.field(body, "state")
local row = id and version and packages.servable(handle, id, version, true)
if state ~= "approved" and state ~= "rejected" then
return web.fail(400, "badState", "state is approved or rejected")
end
if not row then
return web.fail(404, "noVersion", "no such package or version")
end
local signature = web.field(body, "signature")
if state == "approved" then
if not signature then
return web.fail(400, "noSignature", "approving needs the repository's signature of the package")
end
local sigFile = blobs.workPath(settings.dataDir)
fsWrite(sigFile, signature)
local status, _, why = dvxpkg({ "verify", blobs.path(settings.dataDir, row.sha256), "-s", sigFile, settings.repoKey })
fsRemove(sigFile)
if status ~= 0 then
context.result = "denied"
context.detail = { reason = toolReason(why) }
return web.fail(400, "badSignature", "that is not the repository's signature of this package: " .. toolReason(why))
end
end
packages.review(handle, id, version, state == "approved", signature, body.note)
context.action = (state == "approved") and "version.approve" or "version.reject"
context.targetType = "package"
context.targetId = id
context.targetVersion = version
context.description = string.format("%s %s %s %s%s", svc:operatorName(context), state, id, version,
body.note and (": " .. tostring(body.note)) or "")
context.detail = { state = state, note = body.note }
return web.json(200, { package = id, version = version, state = state })
end)
-- Operator: {package, state: published or withdrawn}. Withdrawn packages are not listed or handed
-- out; the copies installed keep working, and stop being offered updates.
svc:route("POST", "/v1/admin/package/state", "admin.packageState", function(req, context)
local ok, refusal = svc:operator(req, context)
if not ok then return refusal end
local body, bad = web.body(req)
if not body then return bad end
local id = web.field(body, "package")
local state = web.field(body, "state")
if state ~= "published" and state ~= "withdrawn" then
return web.fail(400, "badState", "state is published or withdrawn")
end
if not id or packages.setState(handle, id, state) == 0 then
return web.fail(404, "noPackage", "no such package")
end
context.targetType = "package"
context.targetId = id
context.description = string.format("%s set %s to %s", svc:operatorName(context), id, state)
return web.json(200, { package = id, state = state })
end)
-- Serving. The main context listens and does the chores; serveContexts serving contexts, each with
-- its own database handle and routes, accept on its listener, so a slow download to a DOS machine
-- holds up only the context sending it. Each connection goes to one of them; with every one busy,
-- new connections wait in the system's queue until one is free. An accept that fails (a client gone
-- before it was taken, no descriptors to spare) is under pcall so it cannot end a serving context;
-- the pause after it is outside, where calog's stop (calogExit, Ctrl-C, SIGTERM) still reaches.
if worker then
local listener = kvGet(LISTENER_KEY)
while true do
local ok, conn = pcall(tcpAccept, listener, STOP_MS)
if not ok then
timeSleep(RETRY_MS)
elseif conn then
server:handle(conn)
end
end
end
local listener = tcpListen(settings.bindPort, { host = settings.bindHost })
if not listener then
calogPrint(string.format("dvxserver: cannot listen on %s:%d", settings.bindHost, settings.bindPort))
calogExit(1)
end
kvSet(CONFIG_KEY, configPath)
kvSet(LISTENER_KEY, listener)
local script = HERE .. "/dvxserver.lua"
for n = 1, settings.serveContexts do
taskSpawn("lua", string.format("DVX_WORKER = %d\nDVX_HERE = %q\nassert(load(fsRead(%q), %q))()", n, HERE, script, "@" .. script))
end
audit.write(log, { requestId = "-", action = "service.start", result = "ok",
description = string.format("the service started, schema %d, listening on %s:%d with %d serving contexts",
schemaVersion, settings.bindHost, settings.bindPort, settings.serveContexts),
detail = { schema = schemaVersion, host = settings.bindHost, port = settings.bindPort, contexts = settings.serveContexts } })
calogPrint(string.format("dvx packages: schema %d, listening on %s:%d for %s (%d serving contexts)",
schemaVersion, settings.bindHost, settings.bindPort, settings.publicOrigin, settings.serveContexts))
-- The chores (queued mail, expired accounts), every CHORE_SECONDS. The pause is short so a stop
-- reaches this context promptly: calog raises it at the next native call, and a sleep does not wake.
local lastChore = 0
while true do
local now = math.floor(timeNow())
if now - lastChore >= CHORE_SECONDS then
lastChore = now
svc:chores()
end
timeSleep(STOP_MS)
end