// ── 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 }