Files
Module-Rust/server/router/admin/config.controller.js
wtclaude 654a24585d
Some checks failed
PR Checks / server-tests (pull_request) Failing after 16s
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Failing after 37s
fix(rust): protocol 13 — a configuration save that settles after its reload (F9, D179)
The plugin now answers a save once the files are written, with pending and
a writeId, and reports the reload later as a config.outcome event. The save
is recorded as reloading with a settle_by of two plugin ceilings plus slack
on the database's clock; ingest settles the row by (server, writeId), only
while it is still reloading, so a replay moves nothing and a late outcome
still lands. A row past settle_by reads as lost.

GET /admin/rust/config/:serverId/writes/:writeId serves the poll; the page
polls it every two seconds, holds the Save button while it waits, and says
whether a rolled-back plugin came back on its old file. config.outcome is
a staff kind: it carries the server's log tail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 17:16:45 -05:00

384 lines
14 KiB
JavaScript
Raw 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 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(model.shapeCatalogue(reply.data))
}
/** 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' })
}
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
return res.json(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' })
}
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
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: 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 {
return res.json({ writes: await db.recentWrites(req.params.serverId, req.query.limit) })
} 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' })
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' })
}
}
/**
* 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 : 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
}
function pluginOf(path) {
return String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '')
}
/** 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 }