// ── Admin · Rust · Mod configuration ────────────────────────────────────── // // Mounted under the admin tier's `/rust` prefix, so every path here is // `/api/v1/admin/rust/config…`. A **nested** `use()` rather than a second mount, // because a mount prefix is one path segment — core's own check is // `/^\/[a-z0-9][a-z0-9-]*$/`, so `/rust/config` could never be declared in // `module.json`. (The OpenAPI generator follows the require and prefixes these // correctly regardless, which phase 7 established the hard way.) // // **Every route is `requireRole('admin')`**, on top of the tier's own gate. This // is a website form writing files onto a game host and reloading its plugins, // which is the most powerful thing this module can do to somebody's server. // There is still no module-declared site permission at MODULE_API 1.10.0 — R18 // asked for one and hits the same wall phase 7 did — so role is the whole of the // available vocabulary, and `admin` is the honest choice within it. const core = require('../../core') const express = core.express const config = require('./config.controller') const { requireRole, validate } = core.middleware const { body, param, query } = core.validator const configRouter = express.Router() /** A server id, as every other route in this module spells it. */ const SERVER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/ /** * A plugin name to reload. * * Shape only. Whether the name is loaded — and whether it is the bridge itself, * which cannot reload itself without closing the link carrying the answer — is * the plugin's decision, because it is the only process that knows. */ const PLUGIN_NAME = /^[A-Za-z0-9_.-]{1,128}$/ configRouter.get( '/:serverId/files', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Every plugin configuration file on one server' // #swagger.description = 'A live recursive walk of the game host’s configuration directory, grouped by the plugin each file probably belongs to, plus every plugin currently loaded. The root comes from the mod framework, so it is `oxide/config` on Oxide and `carbon/configs` on Carbon. Files past the size limit are listed and marked un-editable rather than hidden. The game’s data directory is never walked.' /* #swagger.responses[200] = { description: 'The configuration tree and the loaded plugins' } */ /* #swagger.responses[503] = { description: 'The sidecar or the game is unreachable' } */ requireRole('admin'), param('serverId').matches(SERVER_ID), validate, config.listFiles, ) configRouter.get( '/:serverId/file', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'One configuration file' // #swagger.description = 'The file’s text, the version a save must present back, and the field list the generated form is drawn from — types, keys, and which values are credentials. A file that is already broken on disk still opens, with the parse error, because the raw tier is the only thing that can fix it.' /* #swagger.responses[200] = { description: 'The file and the reading of it' } */ /* #swagger.responses[400] = { description: 'Not a configuration path' } */ /* #swagger.responses[503] = { description: 'The sidecar or the game is unreachable' } */ requireRole('admin'), param('serverId').matches(SERVER_ID), query('path').isString().isLength({ min: 1, max: 255 }), validate, config.readFile, ) configRouter.post( '/:serverId/file', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Save a configuration file and reload its plugin' // #swagger.description = 'Send `edits` (the generated form: pointers and literals, type-preserving) or `text` (the raw tier: the whole document). `version` must match what the host holds or the save is refused 409 with the current file. The game backs the file up, writes it, reloads the named plugin, and **restores the old file automatically** if the plugin does not come back — which is answered 200 with `report.rolledBack`, because a rollback is a round trip that worked and an edit that did not.' /* #swagger.responses[200] = { description: 'What happened: applied, or rolled back with the reason' } */ /* #swagger.responses[400] = { description: 'Invalid body, an edit the form may not make, or a locked key' } */ /* #swagger.responses[409] = { description: 'The file changed on the host since it was read' } */ /* #swagger.responses[503] = { description: 'The sidecar or the game is unreachable' } */ requireRole('admin'), param('serverId').matches(SERVER_ID), body('path').isString().isLength({ min: 1, max: 255 }), body('version').isString().isLength({ min: 1, max: 64 }), body('reload').optional({ values: 'falsy' }).matches(PLUGIN_NAME), // One tier or the other, never both and never neither. `edits` carries the // form's pointers and literals; `text` is the whole document. body('edits').optional().isArray({ max: 500 }), body('text').optional().isString().isLength({ max: 262144 }), body().custom((value) => { const hasEdits = Array.isArray(value.edits) const hasText = typeof value.text === 'string' if (hasEdits === hasText) throw new Error('send either edits or text') return true }), validate, config.writeFile, ) configRouter.get( '/:serverId/writes', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Recent configuration writes' // #swagger.description = 'The audit trail for one server: who changed which field, from what to what, whether the plugin reloaded, and whether the change was rolled back. Refused and unreachable attempts are recorded too — an operator asking why a setting is not what they set needs to see that somebody tried. Values of credential-shaped fields are never stored.' /* #swagger.responses[200] = { description: 'The recent writes, newest first' } */ requireRole('admin'), param('serverId').matches(SERVER_ID), query('limit').optional().isInt({ min: 1, max: 200 }), validate, config.history, ) module.exports = configRouter