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
384 lines
14 KiB
JavaScript
384 lines
14 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 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 }
|