Files
Module-Rust/server/router/admin/config.controller.js
wtclaude 7a0d0842ba
All checks were successful
PR Checks / client-build (pull_request) Successful in 54s
PR Checks / server-tests (pull_request) Successful in 1m0s
PR Checks / frozen-manifest (pull_request) Successful in 1m19s
feat(demo): the public demo's module-side locks and seed hook
website/DEMO_MODE.md §3.2, §3.4, §3.5, §7.3 and §7.5 (step 2 of §10).
Nothing changes off the demo; everything below sits behind ctx.demoMode.

- demoLocks in module.json: PUT and DELETE /admin/rust/servers/:id.
- The console-command deny list (DM11): quit, restart, oxide.unload,
  oxide.reload, rcon.*, fps.limit and server.tickrate, by first word,
  with global. stripped and o. read as oxide. Refused at save through
  core's demoRefuses (MODULE_API 1.19.0, DM18) and again at run, with
  retry: false, before the rig is asked. Teardown commands too.
- The config editor (DM14): the bridge's files are left out of the
  list and refused for reading, writing and in the write history, by
  name (canonicalised) and by the catalogue's self. Other plugins'
  credential fields are redacted in the text and the form and made
  read-only; raw-JSON saves are refused; a file that does not parse is
  withheld. The admin server list leaves out the sidecar URL.
- onDemoSeed (DM15): upserts the rig's server row with an encrypted
  token and demo-player's stand-in Steam link.
- The release's install manifest carries protocol, read from
  PROTOCOL_VERSION, for the wipe's bundle match (DM12).

coreApi is ^1.19.0; ci/core-ref.json pins website#224's head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 03:40:30 -05:00

430 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Admin · Rust · Mod configuration ──────────────────────────────────────
//
// R18. Five routes: list the tree, read a file, write a file, read what has
// been written lately, and read one write while its reload settles. The first
// three are live round trips to a game host — nothing here is cached, because a
// cached config is an edit somebody made over SSH that this website then
// silently overwrote. The last two read this module's own audit table.
//
// ── The write is three steps and the order is the whole design ────────────
//
// 1. **Re-read the file from the host.** Form edits are spliced into the text
// that is on disk *now*, not into the text a browser was holding. The
// version the browser presents is checked against the fresh one, and a
// mismatch is a conflict rather than an overwrite.
// 2. **Compose the new bytes here** (D35). The browser sends pointers and
// literals; `configEdit` splices them. It never parses and re-serialises,
// because that is how every untouched `1.0` becomes `1` and how a plugin
// fails to come back from its reload.
// 3. **Hand the whole file to the plugin**, which version-checks it again,
// backs the old one up, writes, reloads, and rolls the write back if the
// plugin fails to load. That last part is the feature; this file reports it.
// Since protocol 13 the plugin answers as soon as the files are written and
// reports the reload later as a `config.outcome` frame (F9), so a save that
// reloads is recorded `reloading` and settled by ingest (D179).
//
// ── What the outcome means ────────────────────────────────────────────────
//
// A `config.report` with `rolledBack: true` is a SUCCESSFUL round trip carrying
// bad news: the edit was undone, the plugin is back on its old config, and the
// admin needs to see the log line that says why. It is not a 5xx, and treating
// it as one would lose the only diagnosis available.
const core = require('../../core')
const configEdit = require('../../configEdit')
const demo = require('../../demo')
const db = require('../../model/config/config.db')
const model = require('../../model/config/config.model')
const servers = require('../../model/servers/servers.model')
const serversDb = require('../../model/servers/servers.db')
const sidecar = require('../../sidecarClient')
const log = core.logger('admin:config')
/** Reads the server row with its token, or answers 404 once, here. */
async function serverOr404(req, res) {
const row = servers.withToken(await serversDb.getServer(req.params.serverId))
if (!row) {
res.status(404).json({ message: 'No such server' })
return null
}
return row
}
/**
* Turns a sidecar failure into a sentence an operator can act on.
*
* The statuses are the ones `sidecarClient` produces, and each names a different
* fix: nothing configured, no credential, the wrong protocol, a game that is
* down, a game that is up and silent.
*/
function unreachable(res, reply, what) {
const messages = {
'not-configured': 'That server has no sidecar URL configured',
'no-token': 'That server has no sidecar token configured',
'protocol-mismatch': 'That server’s sidecar speaks a different protocol version',
unauthorized: 'That server’s sidecar rejected the stored token',
timeout: 'That server’s sidecar did not answer in time',
'http-503': 'The game is not connected to that server’s sidecar',
'http-504': 'The game did not answer in time',
}
const message = messages[reply.status] || `Could not ${what}`
return res.status(503).json({ message, status: reply.status })
}
/** Every settings file on one host, grouped by the plugin that probably owns it. */
async function listFiles(req, res) {
const server = await serverOr404(req, res)
if (!server) return undefined
const reply = await sidecar.configFiles(server)
if (!reply.ok) return unreachable(res, reply, 'read that server’s configuration')
if (reply.data && reply.data.kind === 'config.error') {
return res.status(502).json({ message: refusalMessage(reply.data) })
}
return res.json(forDemo(model.shapeCatalogue(reply.data)))
}
/**
* The catalogue without the bridge's own files, on the demo (DEMO_MODE.md §3.5,
* DM14): they hold its link to the sidecar, so they are not listed, read or
* written. Off the demo the catalogue is untouched.
*/
function forDemo(catalogue) {
if (!demo.isDemo() || !catalogue) return catalogue
return {
...catalogue,
plugins: catalogue.plugins
.filter((group) => !demo.isBridgePlugin(group.plugin, catalogue.self))
.map((group) => ({ ...group, files: group.files.filter((file) => !demo.isBridgePath(file.path, catalogue.self)) })),
}
}
/** A shaped file as the caller may see it: secrets hidden on the demo. */
function visible(shaped) {
return demo.isDemo() ? model.redactSecrets(shaped, demo.REDACTED) : shaped
}
/** The demo's refusal for the bridge's config, in one place. */
function refuseBridge(res) {
return demo.refuse(res, 'The bridge plugin’s configuration is not shown on the demo site.')
}
/** One file: its text, and the reading of it the form is drawn from. */
async function readFile(req, res) {
const path = String(req.query.path || '')
if (!model.isPlausiblePath(path)) {
return res.status(400).json({ message: 'That is not a configuration path' })
}
// Before the host is asked, by the bridge's own name; and again below, by the
// name its catalogue gives, in case the two ever differ.
if (demo.isDemo() && demo.isBridgePath(path, null)) return refuseBridge(res)
const server = await serverOr404(req, res)
if (!server) return undefined
const [fileReply, catalogueReply] = await Promise.all([
sidecar.configFile(server, path),
// Asked alongside, because whether this file is OURS decides whether three
// of its keys are locked (D38) — and the answer is the plugin's own name,
// never a filename this module matched on.
sidecar.configFiles(server),
])
if (!fileReply.ok) return unreachable(res, fileReply, 'read that file')
if (fileReply.data && fileReply.data.kind === 'config.error') {
return res.status(refusalStatus(fileReply.data)).json({ message: refusalMessage(fileReply.data) })
}
const self = catalogueReply.ok && catalogueReply.data ? catalogueReply.data.self : null
if (demo.isDemo() && demo.isBridgePath(path, self)) return refuseBridge(res)
return res.json(visible(model.shapeFile(fileReply.data, { self })))
}
/**
* Save one file, and reload whatever owns it.
*
* Two tiers in one route, because they are one action with two ways of saying
* what changed: `edits` is the generated form, `text` is the raw editor.
*/
async function writeFile(req, res) {
const path = String(req.body.path || '')
const tier = Array.isArray(req.body.edits) ? 'form' : 'raw'
const reload = req.body.reload ? String(req.body.reload) : null
const serverId = req.params.serverId
if (!model.isPlausiblePath(path)) {
return res.status(400).json({ message: 'That is not a configuration path' })
}
if (demo.isDemo()) {
if (demo.isBridgePath(path, null)) return refuseBridge(res)
// A raw document sent back from the demo holds the placeholders, and would
// write them over the real values. The form splices only what changed.
if (tier === 'raw') return demo.refuse(res, 'Raw JSON saves are turned off on the demo site. Use the form.')
if (model.editsTouchSecret(req.body.edits)) {
return demo.refuse(res, 'Credential fields are read-only on the demo site.')
}
}
const server = await serverOr404(req, res)
if (!server) return undefined
const [current, catalogue] = await Promise.all([
sidecar.configFile(server, path),
sidecar.configFiles(server),
])
if (!current.ok) return unreachable(res, current, 'read that file')
if (current.data && current.data.kind === 'config.error') {
return res.status(refusalStatus(current.data)).json({ message: refusalMessage(current.data) })
}
const onDisk = current.data
const self = catalogue.ok && catalogue.data ? catalogue.data.self : null
if (demo.isDemo() && demo.isBridgePath(path, self)) return refuseBridge(res)
const locked = model.lockedKeysFor(path, self)
// The browser's version against what is on the host right now. The plugin
// checks this again before it writes — this check exists so that a conflict
// is reported with the current file in hand, which is what a person needs to
// merge their change rather than retype it.
if (String(req.body.version || '') !== String(onDisk.version)) {
return res.status(409).json({
message: 'That file changed on the server since you opened it',
current: visible(model.shapeFile(onDisk, { self })),
})
}
let text
let changes
if (tier === 'form') {
const applied = configEdit.applyEdits(onDisk.text, req.body.edits, { locked })
if (applied.error) return res.status(400).json({ message: applied.error })
text = applied.text
changes = applied.changes
} else {
text = String(req.body.text || '')
try {
configEdit.scan(text)
} catch (err) {
return res.status(400).json({ message: `That is not valid JSON: ${err.message}` })
}
const broken = model.lockedChanges(onDisk.text, text, locked)
if (broken.length > 0) {
return res.status(400).json({
message: `${broken.join(', ')} cannot be changed from the website`,
locked: broken.map((key) => ({ key, reason: model.LOCKED_REASON[key.toLowerCase()] || null })),
})
}
// A raw save records that the document was replaced rather than a field
// list, because that is what happened. Pretending to know which keys moved
// would mean diffing two documents and reporting a guess as an audit fact.
changes = text === onDisk.text ? [] : [{ path: '(whole file)', from: null, to: null }]
}
if (changes.length === 0) {
return res.json({ changed: false, version: onDisk.version })
}
const reply = await sidecar.configWrite(server, {
files: [{ path, version: onDisk.version, text }],
...(reload ? { reload } : {}),
})
if (!reply.ok) {
await record(req, {
serverId,
path,
self,
reload,
tier,
outcome: 'unreachable',
changes,
versionBefore: onDisk.version,
detail: reply.status,
})
return unreachable(res, reply, 'write that file')
}
if (reply.data && reply.data.kind === 'config.error') {
await record(req, {
serverId,
path,
self,
reload,
tier,
outcome: 'refused',
changes,
versionBefore: onDisk.version,
detail: refusalMessage(reply.data),
})
return res.status(refusalStatus(reply.data)).json({ message: refusalMessage(reply.data) })
}
const report = model.summariseReport(reply.data)
const after = report && report.files[0] ? report.files[0].version : null
// Protocol 13 (F9, D179): the files are on disk and the reload is still
// running — behind a cold compile it can take longer than any request should.
// The row is `reloading` until ingest settles it from the plugin's
// `config.outcome`; the page polls it by the id returned here.
if (report && report.pending) {
const id = await record(req, {
serverId,
path,
self,
reload,
tier,
outcome: 'reloading',
reloaded: false,
changes,
versionBefore: onDisk.version,
versionAfter: after,
writeId: report.writeId,
settleSeconds: model.settleSeconds(report.ceilingMs),
})
return res.json({ changed: true, pending: true, write: { id, writeId: report.writeId }, report })
}
await record(req, {
serverId,
path,
self,
reload,
tier,
outcome: report && report.rolledBack ? 'rolled-back' : 'applied',
reloaded: Boolean(report && report.reloaded),
changes,
versionBefore: onDisk.version,
versionAfter: after,
detail: report ? report.reason : null,
})
// 200 either way. A rollback is a round trip that worked and an edit that did
// not, and the body says which — collapsing it into a 5xx would throw away
// the log line that explains it.
return res.json({ changed: true, report })
}
/** What has been written to this server's configuration lately, and by whom. */
async function history(req, res) {
try {
const writes = await db.recentWrites(req.params.serverId, req.query.limit)
// The bridge's own writes are left out on the demo, as its files are (§3.5).
return res.json({ writes: demo.isDemo() ? writes.filter((w) => !isBridgeWrite(w)) : writes })
} catch (err) {
log.error('failed to read the configuration history', { error: err.message })
return res.status(500).json({ message: 'Failed to read the configuration history' })
}
}
/**
* One write, for the page waiting on a reload (D179).
*
* `reloading` until ingest settles it, then `applied` or `rolled-back` with the
* reason and the server's log; `lost` once it has gone unanswered past twice the
* plugin's ceiling, which means re-read the file rather than keep waiting.
*/
async function writeStatus(req, res) {
try {
const write = await db.getWrite(req.params.serverId, Number(req.params.writeId))
if (!write) return res.status(404).json({ message: 'No such configuration write' })
if (demo.isDemo() && isBridgeWrite(write)) return refuseBridge(res)
return res.json({ write })
} catch (err) {
log.error('failed to read a configuration write', { error: err.message })
return res.status(500).json({ message: 'Failed to read that configuration write' })
}
}
/** Whether an audit row is a write to the bridge's own config. */
function isBridgeWrite(write) {
return demo.isBridgePlugin(write.plugin, null) || demo.isBridgePath(write.path, null)
}
/**
* One audit row, plus the activity entry core owns. Never lets a logging failure
* fail a save. Returns the row's id, or null when it could not be written.
*/
async function record(req, row) {
let id = null
try {
id = await db.recordWrite({
...row,
plugin: model.isBridgeConfig(row.path, row.self) ? row.self : model.pluginOf(row.path),
reloadTarget: row.reload,
userId: req.user ? req.user.id : null,
})
await core.activity.log({
req,
action: 'rust.config.write',
detail: {
server: row.serverId,
path: row.path,
tier: row.tier,
outcome: row.outcome,
reload: row.reload || null,
fields: Array.isArray(row.changes) ? row.changes.length : 0,
},
})
} catch (err) {
log.error('failed to record a configuration write', { path: row.path, error: err.message })
}
return id
}
/** A `config.error` frame as a sentence. */
function refusalMessage(frame) {
const reasons = {
busy: 'Another configuration write on that server is still finishing',
conflict: 'That file changed on the server since you opened it',
invalid: 'The game refused that file: it is not valid JSON',
missing: 'That file is not on that server',
path: 'That path is not inside the server’s configuration directory',
'too-large': 'That file is larger than the bridge will carry',
'too-many': 'That save touches too many files',
'reload-self': 'The bridge plugin cannot be reloaded from the website',
'reload-failed': 'The game could not reload that plugin',
unwritable: 'The game could not write that file',
unreadable: 'The game could not read that file',
'no-root': 'That framework reports no configuration directory',
}
const base = reasons[frame.reason] || 'The game refused that configuration change'
return frame.detail ? `${base} (${frame.detail})` : base
}
/** A refusal's status: the caller's fault where it is, the far end's where it is not. */
function refusalStatus(frame) {
if (frame.reason === 'conflict') return 409
if (frame.reason === 'busy') return 409
if (['path', 'missing', 'invalid', 'too-large', 'too-many', 'reload-self'].includes(frame.reason)) return 400
return 502
}
module.exports = { listFiles, readFile, writeFile, writeStatus, history, refusalMessage, refusalStatus }