website/DEMO_MODE.md §3.2, §3.4, §3.5, §7.3 and §7.5 (step 2 of §10). Nothing changes off the demo; everything below sits behind ctx.demoMode. - demoLocks in module.json: PUT and DELETE /admin/rust/servers/:id. - The console-command deny list (DM11): quit, restart, oxide.unload, oxide.reload, rcon.*, fps.limit and server.tickrate, by first word, with global. stripped and o. read as oxide. Refused at save through core's demoRefuses (MODULE_API 1.19.0, DM18) and again at run, with retry: false, before the rig is asked. Teardown commands too. - The config editor (DM14): the bridge's files are left out of the list and refused for reading, writing and in the write history, by name (canonicalised) and by the catalogue's self. Other plugins' credential fields are redacted in the text and the form and made read-only; raw-JSON saves are refused; a file that does not parse is withheld. The admin server list leaves out the sidecar URL. - onDemoSeed (DM15): upserts the rig's server row with an encrypted token and demo-player's stand-in Steam link. - The release's install manifest carries protocol, read from PROTOCOL_VERSION, for the wipe's bundle match (DM12). coreApi is ^1.19.0; ci/core-ref.json pins website#224's head. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
368 lines
14 KiB
JavaScript
368 lines
14 KiB
JavaScript
// ── The logic half of configuration-from-the-site ─────────────────────────
|
|
//
|
|
// Everything here is about the difference between what a game host reports and
|
|
// what an admin should be shown. Three jobs:
|
|
//
|
|
// 1. **Group a flat file list by plugin**, because one plugin can own several
|
|
// files and a form that lists 40 paths is not a settings screen.
|
|
// 2. **Say which file is ours, and which keys inside it are locked** (D38). The
|
|
// plugin names itself in the catalogue rather than us matching a filename,
|
|
// so renaming the file cannot quietly unlock the three keys that would cut
|
|
// the link or split a server's history.
|
|
// 3. **Decide nothing about paths.** The only process that can say whether a
|
|
// path resolves inside a configuration directory is the one holding the
|
|
// directory. This file checks SHAPE, so an obviously malformed request is
|
|
// refused before it costs a round trip — never as a substitute for the real
|
|
// check on the host.
|
|
|
|
const configEdit = require('../../configEdit')
|
|
|
|
/**
|
|
* Keys in the bridge plugin's own config that the website may not change (D38).
|
|
*
|
|
* `Host` and `Port` are the link this edit is travelling over, and `ServerId` is
|
|
* how every row this module has ever stored is keyed — changing it does not
|
|
* rename a server, it strands its history and starts a new one under a name
|
|
* nobody chose deliberately. All three are editable on the host, by a person
|
|
* who is standing on it.
|
|
*/
|
|
const LOCKED_KEYS = ['Host', 'Port', 'ServerId']
|
|
|
|
/** What a locked field says for itself, on the screen and in a refusal. */
|
|
const LOCKED_REASON = {
|
|
host: 'the website reaches this server through this address',
|
|
port: 'the website reaches this server through this port',
|
|
serverid: 'every row this site holds for this server is keyed to this id',
|
|
}
|
|
|
|
/**
|
|
* A path shaped like something the host could plausibly have listed.
|
|
*
|
|
* Deliberately narrow and deliberately **not** the security boundary: no `..`,
|
|
* nothing absolute, no drive letter, forward slashes, and it ends in `.json`.
|
|
*/
|
|
const PATH_SHAPE = /^(?!.*\.\.)(?!\/)[A-Za-z0-9 _.\-()[\]]+(?:\/[A-Za-z0-9 _.\-()[\]]+)*\.json$/
|
|
|
|
/**
|
|
* RaidableBases' profiles (PLAN_REDESIGNS §11.4.3, D205): the one named exception
|
|
* to the editor never walking a data directory. The bridge lists them under this
|
|
* virtual prefix, one file deep, and resolves it to that folder alone.
|
|
*/
|
|
const PROFILE_PATH = /^@data\/RaidableBases\/Profiles\/(?!.*\.\.)[A-Za-z0-9 _.\-()[\]]+\.json$/
|
|
|
|
function isPlausiblePath(path) {
|
|
return typeof path === 'string' && path.length > 0 && path.length <= 255 && (PATH_SHAPE.test(path) || PROFILE_PATH.test(path))
|
|
}
|
|
|
|
/**
|
|
* Shapes the plugin's catalogue into the screen's shape: plugins, each with its
|
|
* files, each file saying whether it can be edited and why not.
|
|
*
|
|
* A file whose guessed plugin is not loaded is kept and **marked**, not dropped.
|
|
* An operator whose config for an unloaded plugin vanished from the page would
|
|
* conclude the bridge cannot see it, which is a different and much more alarming
|
|
* problem than the true one.
|
|
*/
|
|
function shapeCatalogue(catalogue) {
|
|
if (!catalogue || typeof catalogue !== 'object') return null
|
|
|
|
const loaded = Array.isArray(catalogue.plugins) ? catalogue.plugins : []
|
|
const byName = new Map(loaded.map((p) => [String(p.name).toLowerCase(), p]))
|
|
const self = catalogue.self ? String(catalogue.self) : null
|
|
|
|
const groups = new Map()
|
|
|
|
for (const file of Array.isArray(catalogue.files) ? catalogue.files : []) {
|
|
const plugin = String(file.plugin || 'unknown')
|
|
const key = plugin.toLowerCase()
|
|
|
|
if (!groups.has(key)) {
|
|
const match = byName.get(key)
|
|
groups.set(key, {
|
|
plugin,
|
|
loaded: Boolean(match),
|
|
title: match ? match.title : null,
|
|
version: match ? match.version : null,
|
|
// The bridge plugin cannot reload itself — the reload would close the
|
|
// link carrying the answer — so the screen says so up front rather than
|
|
// offering a button that always refuses.
|
|
isBridge: self != null && plugin.toLowerCase() === self.toLowerCase(),
|
|
files: [],
|
|
})
|
|
}
|
|
|
|
groups.get(key).files.push({
|
|
path: String(file.path),
|
|
bytes: Number(file.bytes) || 0,
|
|
modified: file.modified ? Number(file.modified) : null,
|
|
editable: file.editable !== false,
|
|
...(file.reason ? { reason: String(file.reason) } : {}),
|
|
// A RaidableBases profile (D205): reloaded through the raids helper, never a plugin reload.
|
|
...(file.profile === true ? { profile: true } : {}),
|
|
})
|
|
}
|
|
|
|
return {
|
|
root: catalogue.root ? String(catalogue.root) : null,
|
|
self,
|
|
truncated: Boolean(catalogue.truncated),
|
|
limits: catalogue.limits || null,
|
|
plugins: [...groups.values()].sort((a, b) => a.plugin.localeCompare(b.plugin)),
|
|
loaded: loaded
|
|
.map((p) => ({ name: String(p.name), title: p.title || null, version: p.version || null }))
|
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The plugin a path belongs to, by its first segment, or its file name for a
|
|
* file at the root. A RaidableBases profile's virtual path names the plugin
|
|
* after its `@data/` prefix, so the audit row says RaidableBases (W8).
|
|
*/
|
|
function pluginOf(path) {
|
|
const text = String(path)
|
|
const parts = text.split('/')
|
|
if (parts[0] === '@data' && parts.length > 2) return parts[1]
|
|
return parts.length > 1 ? parts[0] : text.replace(/\.json$/i, '')
|
|
}
|
|
|
|
/** Whether this file is the bridge's own config, by the name the plugin gave. */
|
|
function isBridgeConfig(path, self) {
|
|
if (!self) return false
|
|
return pluginOf(path).toLowerCase() === String(self).toLowerCase()
|
|
}
|
|
|
|
/** The locked keys for a file: three of them in our own config, none anywhere else. */
|
|
function lockedKeysFor(path, self) {
|
|
return isBridgeConfig(path, self) ? LOCKED_KEYS : []
|
|
}
|
|
|
|
/**
|
|
* Turns one file the host sent into what the form renders.
|
|
*
|
|
* The text is passed through untouched. What is added is the READING of it: the
|
|
* field list, which fields are locked, and which hold something a browser should
|
|
* mask by default.
|
|
*/
|
|
function shapeFile(file, { self = null, maxDepth = 6 } = {}) {
|
|
if (!file || typeof file.text !== 'string') return null
|
|
|
|
const locked = lockedKeysFor(file.path, self)
|
|
let fields = null
|
|
let parseError = null
|
|
|
|
try {
|
|
fields = configEdit.describe(configEdit.scan(file.text), { maxDepth, locked })
|
|
} catch (err) {
|
|
// A config already broken on disk still opens — in the raw tier, which is
|
|
// the only thing that can fix it. A page that refused to show a broken file
|
|
// would send somebody to SSH for the one job this feature exists to do.
|
|
parseError = err.message
|
|
}
|
|
|
|
return {
|
|
path: String(file.path),
|
|
plugin: file.plugin ? String(file.plugin) : null,
|
|
version: String(file.version),
|
|
bytes: Number(file.bytes) || 0,
|
|
modified: file.modified ? Number(file.modified) : null,
|
|
text: file.text,
|
|
fields,
|
|
parseError,
|
|
locked: locked.map((key) => ({ key, reason: LOCKED_REASON[key.toLowerCase()] || null })),
|
|
isBridge: isBridgeConfig(file.path, self),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A shaped file with every credential-shaped value hidden, for the public demo
|
|
* (DEMO_MODE.md §3.5, DM14). The form's mask is not a lock (DM10), so on the
|
|
* demo the value never leaves the module:
|
|
*
|
|
* - in `text`, each flagged string or number is spliced out for the
|
|
* placeholder, from the end backwards so the spans stay true;
|
|
* - in `fields`, a flagged field carries the placeholder and is `locked`,
|
|
* so the form shows it read-only, and the save refuses an edit to it anyway.
|
|
*
|
|
* "Flagged" is the editor's own reading (`configEdit.isSecretKey`, inherited by
|
|
* everything under a flagged key), so the demo hides exactly what the form masks.
|
|
* A file that does not parse cannot be read for its secrets, so its text is
|
|
* withheld whole: the lock fails closed.
|
|
*/
|
|
function redactSecrets(shaped, placeholder) {
|
|
if (!shaped) return shaped
|
|
|
|
let root
|
|
try {
|
|
root = configEdit.scan(shaped.text)
|
|
} catch {
|
|
return { ...shaped, text: '', fields: null, redacted: true, withheld: 'This file does not parse, so it is not shown on the demo site.' }
|
|
}
|
|
|
|
const spans = []
|
|
const visit = (node, secret) => {
|
|
if (node.type === 'object') {
|
|
for (const child of node.children) visit(child, secret || configEdit.isSecretKey(child.key))
|
|
} else if (node.type === 'array') {
|
|
for (const child of node.children) visit(child, secret)
|
|
} else if (secret && (node.type === 'number' || (node.type === 'string' && node.value !== ''))) {
|
|
spans.push(node)
|
|
}
|
|
}
|
|
visit(root, false)
|
|
|
|
let text = shaped.text
|
|
for (const node of [...spans].sort((a, b) => b.start - a.start)) {
|
|
text = text.slice(0, node.start) + JSON.stringify(placeholder) + text.slice(node.end)
|
|
}
|
|
|
|
const fields = Array.isArray(shaped.fields)
|
|
? shaped.fields.map((field) => {
|
|
if (!field.secret) return field
|
|
const leaf = field.type === 'number' || field.type === 'string'
|
|
const { raw, ...rest } = field
|
|
return {
|
|
...rest,
|
|
...(leaf && !(field.type === 'string' && field.value === '') ? { value: placeholder } : {}),
|
|
locked: true,
|
|
}
|
|
})
|
|
: shaped.fields
|
|
|
|
return { ...shaped, text, fields, redacted: true }
|
|
}
|
|
|
|
/** Whether any edit names a credential-shaped field, as `applyEdits` marks them. */
|
|
function editsTouchSecret(edits) {
|
|
return (edits || []).some(
|
|
(edit) => Array.isArray(edit && edit.pointer) && edit.pointer.some((step) => typeof step === 'string' && configEdit.isSecretKey(step)),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Which locked keys differ between two versions of a document.
|
|
*
|
|
* The form refuses a locked field by pointer, but the **raw tier submits a whole
|
|
* document**, and a document can change `Port` without anything resembling an
|
|
* edit to a field. So the raw tier is checked the only way it can be: by
|
|
* comparing the literals before and after.
|
|
*
|
|
* A file that will not parse is not a way around this — an unparseable document
|
|
* is refused before it gets here.
|
|
*/
|
|
function lockedChanges(before, after, locked) {
|
|
if (!locked || locked.length === 0) return []
|
|
|
|
let a
|
|
let b
|
|
|
|
try {
|
|
a = configEdit.scan(before)
|
|
b = configEdit.scan(after)
|
|
} catch {
|
|
// Nothing can be compared, so nothing is cleared. The caller refuses.
|
|
return locked.slice()
|
|
}
|
|
|
|
const literal = (root, key) => {
|
|
if (root.type !== 'object') return null
|
|
const node = root.children.find((c) => String(c.key).toLowerCase() === key.toLowerCase())
|
|
return node ? JSON.stringify(node.value) + ':' + (node.raw || '') : null
|
|
}
|
|
|
|
return locked.filter((key) => literal(a, key) !== literal(b, key))
|
|
}
|
|
|
|
/** Every change a report says landed, as one line per file. */
|
|
function summariseReport(report) {
|
|
if (!report || typeof report !== 'object') return null
|
|
|
|
const files = Array.isArray(report.files) ? report.files : []
|
|
|
|
return {
|
|
ok: report.ok !== false,
|
|
reloaded: Boolean(report.reloaded),
|
|
rolledBack: Boolean(report.rolledBack),
|
|
// Protocol 13: the files are written and the reload has not finished. The
|
|
// outcome follows as a `config.outcome` frame naming `writeId`.
|
|
pending: Boolean(report.pending),
|
|
writeId: report.writeId ? String(report.writeId) : null,
|
|
ceilingMs: Number(report.ceilingMs) > 0 ? Number(report.ceilingMs) : null,
|
|
restored: typeof report.restored === 'boolean' ? report.restored : null,
|
|
reason: report.reason ? String(report.reason) : null,
|
|
// The plugin only reads this on the failure path, and it is the difference
|
|
// between "your change was undone" and "your change was undone BECAUSE line
|
|
// 14 is not valid for that field".
|
|
log: report.log ? String(report.log).slice(-4000) : null,
|
|
files: files.map((f) => ({
|
|
path: String(f.path),
|
|
version: f.version ? String(f.version) : null,
|
|
bytes: f.bytes != null ? Number(f.bytes) : null,
|
|
// Both frameworks merge missing defaults on load and save the file back,
|
|
// so the file after a successful reload is regularly not the file we
|
|
// wrote. Saying so keeps an operator from reading it as our bug.
|
|
rewritten: Boolean(f.rewritten),
|
|
})),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How long a pending write may go unanswered before it is lost, in seconds.
|
|
*
|
|
* The plugin waits at most one ceiling for the edit's reload and one more for a
|
|
* restore's; ingest reads the feed every few seconds on top. Past both, with a
|
|
* margin, nothing is coming — the plugin was reloaded or the link dropped — and
|
|
* the page says so instead of spinning. A plugin that sent no ceiling gets the
|
|
* one protocol 13 shipped with.
|
|
*/
|
|
const SETTLE_SLACK_SECONDS = 20
|
|
const DEFAULT_CEILING_MS = 30 * 1000
|
|
|
|
function settleSeconds(ceilingMs) {
|
|
const ceiling = Number(ceilingMs) > 0 ? Number(ceilingMs) : DEFAULT_CEILING_MS
|
|
return Math.ceil((2 * ceiling) / 1000) + SETTLE_SLACK_SECONDS
|
|
}
|
|
|
|
/**
|
|
* A `config.outcome` frame as the row it settles, or null when it names no write.
|
|
*
|
|
* `applied` covers every case where the edit is on disk — reloaded, or standing
|
|
* because the framework never reloaded it (the reason says which). A rollback is
|
|
* `rolled-back` whether or not the plugin came back on the old file; `restored`
|
|
* says which, and it is the difference between "try again" and "go and look".
|
|
*/
|
|
function outcomeOf(frame) {
|
|
if (!frame || typeof frame !== 'object' || !frame.writeId) return null
|
|
|
|
const report = summariseReport(frame)
|
|
const first = report.files[0]
|
|
|
|
return {
|
|
writeId: String(frame.writeId),
|
|
outcome: report.rolledBack ? 'rolled-back' : 'applied',
|
|
reloaded: report.reloaded,
|
|
restored: report.rolledBack ? report.restored : null,
|
|
versionAfter: first ? first.version : null,
|
|
detail: report.reason,
|
|
log: report.log,
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
LOCKED_KEYS,
|
|
LOCKED_REASON,
|
|
PATH_SHAPE,
|
|
isPlausiblePath,
|
|
shapeCatalogue,
|
|
shapeFile,
|
|
redactSecrets,
|
|
editsTouchSecret,
|
|
pluginOf,
|
|
isBridgeConfig,
|
|
lockedKeysFor,
|
|
lockedChanges,
|
|
summariseReport,
|
|
outcomeOf,
|
|
settleSeconds,
|
|
}
|