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
430 lines
16 KiB
JavaScript
430 lines
16 KiB
JavaScript
// ── 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 }
|