feat(rust): mod configuration from the site, and an editor that will not rewrite a float
R18's two tiers: a form generated from a config file's own values, and raw JSON for what a form cannot express. Admin → Rust mod config, one live round trip per action, nothing cached between a browser and a game host's disk. `configEdit.js` is the part that could not be done naively. JavaScript cannot tell `1` from `1.0`, and both mod frameworks deserialize a config into typed C# classes — so a read-modify-write silently rewrites every whole-numbered float as an integer on fields nobody touched, and a plugin that then throws at load does not come back. It never parses, mutates and re-serialises: it records the SOURCE SPAN of every value and splices literals into them, so an untouched `1.0` is still `1.0` and a number an admin types travels as text the whole way (D35/D36). The bridge's own config is editable with `Host`, `Port` and `ServerId` locked, in the form and in the raw tier, because either would cut the link carrying the edit or strand every row this site holds (D38). Credentials render masked with a reveal; the raw tier shows them (D37) and the audit trail never does. `rust_config_writes` records every save including the refused and the rolled back — an operator asking why a setting is not what they set needs to see that somebody tried. Three defects a browser walk found that 179 green tests did not: * every save of the bridge's own config was refused while the page said the opposite — a `<select>` whose value matches no `<option>` shows the first one, so the reload guess `RunicGateway` was on the wire and "nothing" was on the screen; * `btn ghost` is not a class this platform defines (`.btn-ghost` is), so every secondary button in this module has rendered as a primary one since phase 7 — here it made the open file and the active tier indistinguishable; * a save's refusal rendered at the top of a long form, far from the button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
331
server/router/admin/config.controller.js
Normal file
331
server/router/admin/config.controller.js
Normal file
@@ -0,0 +1,331 @@
|
||||
// ── Admin · Rust · Mod configuration ──────────────────────────────────────
|
||||
//
|
||||
// R18. Four routes: list the tree, read a file, write a file, read what has
|
||||
// been written lately. Every one of them is a live round trip 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 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 does not announce itself. That last part is the feature; this file
|
||||
// reports it.
|
||||
//
|
||||
// ── 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
|
||||
|
||||
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 audit row, plus the activity entry core owns. Never lets a logging failure fail a save. */
|
||||
async function record(req, row) {
|
||||
try {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
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, history, refusalMessage, refusalStatus }
|
||||
Reference in New Issue
Block a user