Files
website/server/src/modules/install.js
wtclaude 9b16f39a52
Some checks failed
PR Checks / bot-install (pull_request) Successful in 23s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Failing after 4m23s
feat(modules): the declarative Docker path (phase 4, slice 3)
MODULES declares the module set a deployment runs, one entry per module as
`<id>@<version>=<install manifest URL>`, and the container arrives at it by
itself (MODULE_SYSTEM.md §2.7.2 decision 4). A module already unpacked at the
declared version is a no-op that makes NO network call, so a restart with the
network down comes up unchanged; anything else goes through install.js — same
allowlist, same sha256, same inspect-then-extract — and install() now takes an
`expect: {id, version}` so a URL resolving to another module or version is
refused while it is still only a manifest.

Resolution runs inside start(), between the seed and the require of app.js: the
seed is where the host allowlist setting comes from, and the require is what
scans the volume. That buys it the database, so a compose-installed module gets
the same provenance columns an admin install writes.

A failure is logged and carried, never fatal — an unreachable release host must
not take the site down. The declaration owns what is on the volume; the row owns
whether a module runs, so uninstalling a declared module returns its files at
the next start and leaves it disabled. The admin list gains that as a fourth
source (declared / declaredVersion / declaredError), because a declared module
that failed to resolve has no row, no directory and nothing mounted.

Deferring the app require moved core's schema ahead of the volume scan, and the
module schema-fragment replay was wired to core's schema — so every installed
module silently got no tables. Invisible to the suite (each one stubs the loader
or the pool) and to a smoke on a database that already had the tables; found by
booting against an empty one. ensureSchema() now takes `replayModules: false`
for the one caller that scans later, server.js replays them itself after the
require, and a bootOrder test pins the five steps in the only order they work in.

741 server tests (+18), 187 client (+5); manifest unchanged at 166 public + 2
internal, OpenAPI byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 07:57:08 -05:00

451 lines
18 KiB
JavaScript

// ── Installing and removing a module bundle ────────────────────────────────
//
// Phase 4, slice 1 of docs/website/MODULE_SYSTEM.md §2.7.2 — the consumer half
// of a release the module's own CI already publishes. Nothing here touches the
// database or the loader: this file moves bytes onto the volume and off it, and
// the caller (router/v1/admin/modules.controller.js) writes down what happened.
//
// The shape of an install, and why it is this shape:
//
// 1. The operator pastes the URL of a release's install manifest — a small
// JSON document naming the artifact, its sha256 and its size (decision 2).
// There is no catalog, because a catalog would make core's release cadence
// decide which modules can exist.
// 2. Every URL fetched — the manifest, the artifact, and every redirect hop —
// is checked against the admin-managed host allowlist (decision 6). That is
// what keeps a pasted URL from also being an SSRF primitive.
// 3. The artifact is streamed to a temporary file under a byte cap, hashed as
// it arrives, and compared against the manifest's `sha256`. The hash is the
// trust anchor; releases are unsigned and say so.
// 4. modules/archive.js inspects the archive in full before a single byte is
// unpacked, and only then unpacks it — into a TEMPORARY directory.
// 5. The unpacked tree's own `module.json` must agree with the manifest about
// what it is. A manifest that promises `uo` and delivers something else is
// refused rather than installed under the name it was promised.
// 6. Only then is anything moved into `modules/<id>/`, and the directory it
// replaces is kept aside until the move has succeeded.
//
// **Nothing is written into the modules directory until every check has passed**,
// and that is not belt-and-braces. Verified against node-tar 7.5.22 while writing
// this: extracting an archive whose fourth member escapes upward throws — and
// leaves the first three members on disk. A loader scans that directory at
// require time on the next boot; a half-unpacked module is a module.
//
// One thing this file deliberately does NOT do: mount anything. Installing puts
// a directory on the volume, and §1.12 means the volume is read at require time,
// so the module appears when the process restarts. The admin screen offers that
// restart (decision 1); it is not implied here.
const crypto = require('crypto')
const fs = require('fs')
const fsp = require('fs/promises')
const path = require('path')
const { Readable, Transform } = require('stream')
const { pipeline } = require('stream/promises')
const archive = require('./archive')
const loader = require('./loader')
const log = require('../utils/logger')('modules')
// An install manifest is a small JSON document. A megabyte of it is not a
// manifest, and reading it into memory unbounded is the one place this file
// would otherwise trust a remote length.
const MAX_MANIFEST_BYTES = 256 * 1024
// The artifact cap is the archive's own unpacked cap — a compressed bundle
// larger than what it is allowed to unpack to has nothing to offer.
const MAX_ARTIFACT_BYTES = archive.MAX_BYTES
const FETCH_TIMEOUT_MS = 30000
// Gitea serves a release asset through at least one redirect. Following them is
// necessary; following them blindly is how an allowlist gets bypassed, so each
// hop is re-checked and the chain is bounded.
const MAX_REDIRECTS = 5
// Same id rule the loader enforces when scanning, restated rather than imported
// so an install cannot put a directory on the volume that the loader would then
// refuse to look at.
const ID = /^[a-z][a-z0-9-]{1,31}$/
const SHA256 = /^[0-9a-f]{64}$/
class InstallError extends Error {
constructor(message, { status = 400 } = {}) {
super(message)
this.name = 'InstallError'
// Carried so the controller can answer 400 for "your URL is wrong" and 502
// for "the host you named misbehaved" without re-deriving it from the text.
this.status = status
}
}
// ── The allowlist ──────────────────────────────────────────────────────────
// The settings row the allowlist lives in (decision 6): seeded from
// MODULE_SOURCE_HOSTS on a fresh install and admin-managed from then on. The KEY
// lives here rather than in the admin controller because it is now read from two
// places — the controller, and the boot-time resolution of the declared module
// set (modules/declared.js), which has no route and no request.
const HOSTS_SETTING = 'module_source_hosts'
/**
* Parse the stored allowlist setting into hostnames.
*
* Comma or whitespace separated, case-insensitive, empty entries dropped. An
* empty list means nothing may be installed from anywhere — a refusal, never a
* wildcard. That direction matters: a setting an admin accidentally blanks
* should stop installs, not permit every host on the internet.
*/
function parseHosts(value) {
return String(value || '')
.split(/[,\s]+/)
.map((h) => h.trim().toLowerCase())
.filter(Boolean)
}
/**
* Check one URL against the allowlist, and return it parsed.
*
* `https` only. A plaintext fetch of code this process is going to execute is
* not something an operator should be able to opt into by typing a URL, and the
* sha256 does not help — whoever can rewrite the artifact in flight can rewrite
* the manifest that declares its hash.
*/
function checkUrl(raw, hosts) {
let url
try {
url = new URL(String(raw))
} catch {
throw new InstallError(`"${raw}" is not a valid URL`)
}
if (url.protocol !== 'https:') {
throw new InstallError(`only https URLs may be installed from (got "${url.protocol}")`)
}
if (!hosts.length) {
throw new InstallError(
'no module source hosts are allowed — set one in Admin → Modules before installing',
)
}
if (!hosts.includes(url.hostname.toLowerCase())) {
throw new InstallError(
`"${url.hostname}" is not an allowed module source host (allowed: ${hosts.join(', ')})`,
)
}
return url
}
// ── Fetching ───────────────────────────────────────────────────────────────
/**
* GET a URL, following redirects by hand so every hop is re-checked.
*
* `fetch`'s own redirect following would take the first hop off the allowlist
* and the rest wherever it was pointed, which is exactly the hole the allowlist
* exists to close.
*
* `fetchImpl` is the same injection seam `replayFragments({query})` and
* `lifecycle.boot({model})` use, and it exists for the same reason: the rules
* this file enforces — https only, allowlisted host, allowlisted REDIRECT host,
* bounded body, matching hash — are all decisions about a response, and testing
* them against a real TLS server would mean testing Node's certificate handling
* instead. Production never passes it.
*
* @returns {Promise<Response>} a response whose body has not been read.
*/
async function get(rawUrl, hosts, fetchImpl = fetch) {
let url = checkUrl(rawUrl, hosts)
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
let res
try {
res = await fetchImpl(url, {
redirect: 'manual',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: { accept: '*/*' },
})
} catch (err) {
throw new InstallError(`could not reach ${url.hostname}: ${err.message}`, { status: 502 })
}
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get('location')
if (!location) throw new InstallError(`${url.hostname} redirected without a location`, { status: 502 })
// Resolved against the current URL, then re-checked — a relative redirect
// is normal and a cross-host one is the thing being guarded against.
url = checkUrl(new URL(location, url).toString(), hosts)
continue
}
if (!res.ok) {
throw new InstallError(`${url.href} returned ${res.status}`, { status: 502 })
}
return res
}
throw new InstallError(`too many redirects (more than ${MAX_REDIRECTS})`, { status: 502 })
}
/** Read a bounded response body as text. */
async function readText(res, cap, what) {
const declared = Number(res.headers.get('content-length'))
if (Number.isFinite(declared) && declared > cap) {
throw new InstallError(`${what} is larger than ${cap} bytes`)
}
const buf = Buffer.from(await res.arrayBuffer())
// Checked again after reading: content-length is the server's claim, not a
// limit it is obliged to honour.
if (buf.length > cap) throw new InstallError(`${what} is larger than ${cap} bytes`)
return buf.toString('utf8')
}
/**
* Stream a response body to a file, hashing as it goes and stopping at the cap.
*
* The hash is computed from the bytes that were written rather than by re-reading
* the file, so there is no window in which the file could differ from what was
* verified.
*/
async function download(res, dest, cap) {
const hash = crypto.createHash('sha256')
let bytes = 0
const body = Readable.fromWeb(res.body)
const counter = new Transform({
transform(chunk, _enc, cb) {
bytes += chunk.length
if (bytes > cap) {
cb(new InstallError(`the artifact is larger than ${Math.round(cap / 1024 / 1024)} MB`))
return
}
hash.update(chunk)
cb(null, chunk)
},
})
await pipeline(body, counter, fs.createWriteStream(dest))
return { sha256: hash.digest('hex'), bytes }
}
// ── The install manifest ───────────────────────────────────────────────────
/**
* Fetch and validate a release's install manifest.
*
* The shape is the one module-uo's release workflow already writes:
* `{schema, id, name, version, coreApi, artifact, url, sha256, size}`. Only the
* fields this side needs are required — a module publisher may carry more.
*/
async function fetchManifest(manifestUrl, hosts, fetchImpl = fetch) {
const res = await get(manifestUrl, hosts, fetchImpl)
const text = await readText(res, MAX_MANIFEST_BYTES, 'the install manifest')
let manifest
try {
manifest = JSON.parse(text)
} catch (err) {
throw new InstallError(`the install manifest is not valid JSON: ${err.message}`)
}
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
throw new InstallError('the install manifest is not a JSON object')
}
const { id, name, version, sha256 } = manifest
if (!ID.test(String(id || ''))) {
throw new InstallError(`the install manifest has an invalid module id: ${JSON.stringify(id)}`)
}
if (!name || !version) throw new InstallError('the install manifest is missing name or version')
if (!SHA256.test(String(sha256 || '').toLowerCase())) {
throw new InstallError('the install manifest has no valid sha256 for its artifact')
}
// `url` is the absolute artifact URL the publisher wrote; `artifact` is its
// filename. Prefer the URL, fall back to resolving the filename beside the
// manifest — which is where a release's assets sit — so a manifest that
// travelled without its absolute URL still installs.
const artifactUrl = new URL(manifest.url || manifest.artifact || '', manifestUrl).toString()
return {
id: String(id),
name: String(name),
version: String(version),
sha256: String(sha256).toLowerCase(),
size: Number(manifest.size) || null,
artifactUrl,
coreApi: manifest.coreApi ? String(manifest.coreApi) : null,
}
}
// ── Paths on the volume ────────────────────────────────────────────────────
/** Where a module with this id lives. Rejects an id that is not one. */
function moduleDir(id) {
if (!ID.test(String(id || ''))) throw new InstallError(`invalid module id: ${JSON.stringify(id)}`)
return path.join(loader.dir(), String(id))
}
/** Is there a directory for this module on the volume right now? */
function isInstalled(id) {
try {
return fs.statSync(moduleDir(id)).isDirectory()
} catch {
return false
}
}
/**
* The absolute path of a module's `purge.sql`, or null.
*
* Read from the module's own `module.json` on disk rather than from the loader,
* because purge has to work for a module that never loaded — a `startup_failed`
* one is exactly when an operator wants its tables gone.
*/
function purgeFile(id) {
try {
const manifest = JSON.parse(fs.readFileSync(path.join(moduleDir(id), 'module.json'), 'utf8'))
if (!manifest.purge) return null
const file = path.resolve(moduleDir(id), manifest.purge)
// The same containment check the loader applies to `client.entry`: a
// manifest may not point core at a file outside the module.
if (!file.startsWith(moduleDir(id) + path.sep)) return null
return fs.existsSync(file) ? file : null
} catch {
return null
}
}
// ── Install ────────────────────────────────────────────────────────────────
/**
* Download, verify, unpack and install a module from an install-manifest URL.
*
* Never leaves a partial module on the volume: everything happens under a
* scratch directory that is removed on any failure, and the move into place is
* the last step.
*
* `expect` is what the CALLER was promised, as opposed to what the manifest
* promises about itself — the declared module set (modules/declared.js) pins an
* id and a version in the environment, and a URL that resolves to something else
* has to be refused rather than installed. Checked against the manifest, before
* a byte is downloaded: catching it after the unpack would mean the undeclared
* module is already on the volume when the objection is raised. The admin panel
* passes nothing, because there a URL is the whole of what was asked for.
*
* @param {object} args
* @param {string} args.url the install manifest URL the admin pasted
* @param {string[]} args.hosts the allowlist, already parsed
* @param {{id?: string, version?: string}} [args.expect] what the caller pinned
* @returns {Promise<{id,name,version,sha256,source,bytes,replaced}>}
*/
async function install({ url, hosts, expect = null, fetchImpl = fetch }) {
const manifest = await fetchManifest(url, hosts, fetchImpl)
if (expect && expect.id && manifest.id !== expect.id) {
throw new InstallError(
`that URL installs the module "${manifest.id}", but "${expect.id}" was asked for`,
)
}
if (expect && expect.version && manifest.version !== expect.version) {
throw new InstallError(
`that URL installs ${manifest.id} v${manifest.version}, but v${expect.version} was asked for`,
)
}
const target = moduleDir(manifest.id)
const scratch = await fsp.mkdtemp(path.join(loader.dir(), `.install-${manifest.id}-`))
const tarball = path.join(scratch, 'bundle.tar.gz')
const unpacked = path.join(scratch, 'unpacked')
try {
const res = await get(manifest.artifactUrl, hosts, fetchImpl)
const { sha256, bytes } = await download(res, tarball, MAX_ARTIFACT_BYTES)
if (sha256 !== manifest.sha256) {
// The whole trust model in one comparison. Deliberately does not name the
// computed hash in a way that reads like a value to copy into the
// manifest — a mismatch means stop, not reconcile.
throw new InstallError(
`the downloaded artifact does not match the sha256 in the install manifest — refusing to install`,
)
}
if (manifest.size && bytes !== manifest.size) {
throw new InstallError(`the artifact is ${bytes} bytes but the manifest declares ${manifest.size}`)
}
await archive.unpack(tarball, unpacked)
// What the bundle says it is, checked against what the manifest promised.
let inner
try {
inner = JSON.parse(await fsp.readFile(path.join(unpacked, 'module.json'), 'utf8'))
} catch {
throw new InstallError('the bundle has no readable module.json at its root')
}
if (inner.id !== manifest.id) {
throw new InstallError(
`the bundle declares module id "${inner.id}" but the install manifest promised "${manifest.id}"`,
)
}
if (inner.version !== manifest.version) {
throw new InstallError(
`the bundle declares version "${inner.version}" but the install manifest promised "${manifest.version}"`,
)
}
// The swap. The outgoing directory is moved aside rather than deleted first,
// so a failed rename leaves the previous version recoverable instead of
// leaving no module at all — the same reasoning the installer repo applies
// to a ServUO tree.
const replaced = fs.existsSync(target)
const aside = `${target}.replaced-${Date.now()}`
if (replaced) await fsp.rename(target, aside)
try {
await fsp.rename(unpacked, target)
} catch (err) {
if (replaced) await fsp.rename(aside, target).catch(() => {})
throw err
}
if (replaced) await fsp.rm(aside, { recursive: true, force: true })
log.info(`installed module "${manifest.id}" v${manifest.version}`, {
source: url,
sha256: manifest.sha256,
bytes,
replaced,
})
return { ...manifest, source: url, bytes, replaced }
} finally {
await fsp.rm(scratch, { recursive: true, force: true }).catch(() => {})
}
}
/**
* Remove a module's directory from the volume.
*
* Returns whether there was one. Does not touch the database, does not run
* purge.sql, and does not stop the running module — the controller sequences
* all three, because the order matters and only it knows what the operator
* asked for.
*/
async function removeDir(id) {
const dir = moduleDir(id)
if (!fs.existsSync(dir)) return false
await fsp.rm(dir, { recursive: true, force: true })
log.info(`removed module directory for "${id}"`)
return true
}
module.exports = {
InstallError,
HOSTS_SETTING,
parseHosts,
checkUrl,
get,
fetchManifest,
install,
removeDir,
moduleDir,
isInstalled,
purgeFile,
MAX_MANIFEST_BYTES,
MAX_ARTIFACT_BYTES,
}