feat(rust): mod configuration from the site, and an editor that will not rewrite a float #9

Merged
whitlocktech merged 1 commits from feat/phase-7b-config into edge 2026-09-22 15:02:20 +00:00
21 changed files with 2862 additions and 21 deletions

View File

@@ -29,6 +29,7 @@
"server": [
"boot.js",
"catalogue.js",
"configEdit.js",
"core.js",
"db",
"index.js",

View File

@@ -147,6 +147,34 @@ export const adminPermissions = {
req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }),
}
// ── admin · mod configuration (R18) ───────────────────────────────────────
//
// Every call here is a LIVE round trip to a game host, which makes this the only
// section of this file where a call can be slow, or fail because a server is
// off. Nothing is cached anywhere between the browser and the host's disk: a
// cached config is an edit an operator made over SSH that this website then
// silently overwrote.
//
// `save` carries a `version` the host issued with the file. Send a stale one and
// the answer is a 409 with the current file attached, rather than an overwrite
// of whatever somebody else changed in the meantime.
export const adminConfig = {
files: (serverId) => req(`/admin/rust/config/${encodeURIComponent(serverId)}/files`),
file: (serverId, path) =>
req(`/admin/rust/config/${encodeURIComponent(serverId)}/file${query({ path })}`),
// Two tiers, one route. `edits` is the generated form — pointers and literals,
// type-preserving — and `text` is the raw document. A number travels as TEXT
// in both: `1.0` parsed into a JavaScript number and sent back as `1` is the
// whole failure this feature was designed around.
save: (serverId, body) =>
req(`/admin/rust/config/${encodeURIComponent(serverId)}/file`, { method: 'POST', body }),
writes: (serverId, limit = null) =>
req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes${query({ limit })}`),
}
// ── the admin.users.detail extension slot ─────────────────────────────────
//
// The client half of R13's first slot. Core hands the component a `userId` and
@@ -189,6 +217,7 @@ export default {
playerLinks,
admin,
adminPermissions,
adminConfig,
adminUserLinks,
adminUserPermissions,
BASE,

View File

@@ -22,9 +22,10 @@ import Servers from './routes/public/Servers.jsx'
import ServerDetail from './routes/public/ServerDetail.jsx'
import Account from './routes/player/Account.jsx'
import Permissions from './routes/admin/Permissions.jsx'
import ModConfig from './routes/admin/ModConfig.jsx'
import UserRustSections from './routes/admin/UserRustSections.jsx'
import FooterStatus from './components/FooterStatus.jsx'
import { IconKey, IconLink } from './icons.jsx'
import { IconKey, IconLink, IconSliders } from './icons.jsx'
// The module id, exactly as `module.json` spells it. Core keys the registry by it
// and prefixes every route path with it.
@@ -82,7 +83,18 @@ registry.registerRoutes(ID, {
{ path: 'servers/:id', element: <ServerDetail /> },
],
player: [{ path: '', element: <Account /> }],
admin: [{ path: '', element: <Permissions /> }],
admin: [
{ path: '', element: <Permissions /> },
// Phase 7b (R18). A second admin page rather than a tab on the first: the
// permission mirror decides who may do what inside the game, and this edits
// the game host's own files. They are neighbours, not halves of one screen,
// and the nav says so with two rows.
//
// A static segment under the module's namespace, so it lands at
// `/admin/rust/config` and core's admin gate applies to it exactly as it
// does to the page above.
{ path: 'config', element: <ModConfig /> },
],
})
// ── Nav ───────────────────────────────────────────────────────────────────
@@ -127,7 +139,10 @@ registry.registerNav(ID, {
// every sidebar row, and the one without is the only text in a column of glyphs.
registry.registerNav(ID, {
area: 'admin',
items: [{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey }],
items: [
{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey },
{ label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders },
],
})
// ── Extension slots ───────────────────────────────────────────────────────

View File

@@ -62,4 +62,25 @@ export const IconKey = () => (
</Icon>
)
export default { IconLink, IconKey }
/**
* Sliders — the admin sidebar's row for the mod-configuration editor.
*
* Not a gear: core's account row is a gear, and two gears in one sidebar say
* "settings" twice without saying whose. Sliders read as values being tuned,
* which is exactly what that page does to somebody else's game host.
*/
export const IconSliders = () => (
<Icon>
<path d="M4 6h10" />
<path d="M18 6h2" />
<circle cx="16" cy="6" r="2" />
<path d="M4 12h4" />
<path d="M12 12h8" />
<circle cx="10" cy="12" r="2" />
<path d="M4 18h10" />
<path d="M18 18h2" />
<circle cx="16" cy="18" r="2" />
</Icon>
)
export default { IconLink, IconKey, IconSliders }

View File

@@ -0,0 +1,554 @@
// ── Admin · Rust · Mod configuration ──────────────────────────────────────
//
// R18. An admin picks a server, a plugin and a file, changes something, and the
// plugin reloads. This module's second admin page, and the first that writes to
// somebody's filesystem.
//
// **What is on the screen is decided by what is dangerous about the action.**
// Four things are true here that are not true anywhere else in this module, and
// each of them is a piece of the page rather than a line in a doc:
//
// • a save can take a required plugin DOWN. So the reload target is a
// deliberate choice with the folder name as a guess, the result is reported
// as its own panel, and a rollback shows the server's own log line.
// • the form cannot express everything a config holds. A `null`, an empty
// array and anything past the depth limit are marked and sent to the raw
// tier rather than half-drawn.
// • three keys in the bridge's own config would cut the link carrying the
// edit, or split the server's history. They render read-only, with the
// reason (D38).
// • configs hold API keys and Discord webhooks. Those fields render masked
// with a reveal, which is about the shoulder rather than the wire: an admin
// can already read the file over SSH (D37), and the audit trail never
// records the values either way.
import { useCallback, useEffect, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import { ago } from '../../lib/format.js'
import api from '../../api.js'
function Card({ title, subtitle, children, actions }) {
return (
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
{title}
</h2>
{subtitle && (
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
{subtitle}
</span>
)}
<span style={{ flex: 1 }} />
{actions}
</header>
{children}
</section>
)
}
function Warn({ children, tone = '#d08a2a' }) {
return (
<p className="sans" style={{ color: tone, fontSize: '0.78rem', margin: '6px 0 0' }}>
{children}
</p>
)
}
/** A value the form can edit: one row, typed by what the file already holds. */
function Field({ field, value, onChange, revealed, onReveal }) {
const indent = 12 * Math.max(0, field.depth - 1)
const label = (
<label
className="sans"
style={{
flex: '0 0 300px',
paddingLeft: indent,
color: field.locked ? 'var(--ink)' : 'var(--head)',
fontSize: '0.84rem',
wordBreak: 'break-word',
}}
title={field.path}
>
{field.key}
{field.locked && (
<span className="dim" style={{ fontSize: '0.72rem' }}>
{' '}
· read-only
</span>
)}
</label>
)
if (field.type === 'object' || field.type === 'array') {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 0 2px' }}>
<span
className="sans"
style={{ paddingLeft: indent, color: 'var(--head)', fontSize: '0.86rem', fontWeight: 500 }}
>
{field.key || '(the file)'}
</span>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
{field.type === 'array' ? `${field.count} entries` : `${field.count} settings`}
{field.advanced && field.reason ? ` · ${field.reason}` : ''}
</span>
</div>
)
}
if (field.advanced) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
{label}
<span className="sans dim" style={{ fontSize: '0.78rem' }}>
{field.reason} edit it in Raw JSON
</span>
</div>
)
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
{label}
{field.type === 'boolean' ? (
<input
type="checkbox"
checked={Boolean(value)}
disabled={field.locked}
onChange={(event) => onChange(field, event.target.checked)}
/>
) : (
<input
className="input"
style={{ flex: 1, minWidth: 0 }}
type={field.secret && !revealed ? 'password' : 'text'}
value={value === undefined || value === null ? '' : String(value)}
disabled={field.locked}
onChange={(event) => onChange(field, event.target.value)}
/>
)}
{field.secret && !field.locked && (
<button type="button" className="btn btn-ghost" onClick={() => onReveal(field.path)}>
{revealed ? 'Hide' : 'Show'}
</button>
)}
</div>
)
}
/** What the game said happened. The rollback case is the one worth reading. */
function Report({ report }) {
if (!report) return null
const tone = report.rolledBack ? '#e05a5a' : 'var(--ink)'
return (
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 10, marginTop: 10 }}>
<p className="sans" style={{ color: tone, fontSize: '0.84rem', margin: 0 }}>
{report.rolledBack
? 'The plugin did not come back, so the old file was put back automatically.'
: report.reloaded
? 'Saved, and the plugin reloaded.'
: `Saved. ${report.reason || 'Nothing was reloaded.'}`}
</p>
{report.rolledBack && report.reason && (
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '4px 0 0' }}>
{report.reason}
</p>
)}
{report.log && (
<pre
className="sans"
style={{
background: 'var(--line-soft)',
padding: 10,
marginTop: 8,
fontSize: '0.74rem',
maxHeight: 200,
overflow: 'auto',
whiteSpace: 'pre-wrap',
}}
>
{report.log}
</pre>
)}
{report.files.some((f) => f.rewritten) && (
<Warn>
The plugin rewrote the file as it loaded both frameworks add any settings a config is
missing and save it back, so what is on disk now is not byte-for-byte what was sent.
</Warn>
)}
</div>
)
}
export default function ModConfig() {
const [serverId, setServerId] = useState('')
const [path, setPath] = useState('')
const [tier, setTier] = useState('form')
const [edits, setEdits] = useState({})
const [raw, setRaw] = useState('')
const [reload, setReload] = useState('')
const [revealed, setRevealed] = useState({})
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [report, setReport] = useState(null)
const [fileNonce, setFileNonce] = useState(0)
const { data: servers, error: serverError } = useAsync(() => api.admin.listServers(), [])
// The tree is asked for per server and never cached across one: what is on a
// host's disk has no stale answer worth showing, and a plugin loaded a minute
// ago has to be able to appear.
const { data: tree, error: treeError } = useAsync(
() => (serverId ? api.adminConfig.files(serverId) : Promise.resolve(null)),
[serverId],
)
const { data: file, error: fileError } = useAsync(
() => (serverId && path ? api.adminConfig.file(serverId, path) : Promise.resolve(null)),
[serverId, path, fileNonce],
)
const reset = useCallback(() => {
setEdits({})
setRevealed({})
setError('')
}, [])
// A freshly opened file starts from what the host holds: the raw editor's text
// and the reload target's guess both come from the answer rather than from
// whatever the previous file left behind.
//
// **The guess is only taken when the dropdown actually offers it.** A `<select>`
// whose value matches no `<option>` displays the first one, so a guess of
// `RunicGateway` — which is deliberately not offered, because the bridge cannot
// reload itself — put "nothing — just write the file" on the screen while the
// request carried `reload: RunicGateway`, and every save of our own config was
// refused for a reason the page had just said did not apply.
useEffect(() => {
if (!file) return
setRaw(file.text)
const offered = (tree ? tree.loaded : []).some(
(p) => p.name === file.plugin && p.name !== (tree && tree.self),
)
setReload(offered ? file.plugin : '')
reset()
}, [file, tree, reset])
useEffect(() => {
setPath('')
setReport(null)
}, [serverId])
if (serverError) return <ErrorState error={serverError} />
if (!servers) return <Loading />
const rows = servers.servers || servers || []
const change = (field, value) => setEdits((current) => ({ ...current, [field.path]: { field, value } }))
const save = async () => {
setBusy(true)
setError('')
setReport(null)
try {
const body =
tier === 'form'
? {
path,
version: file.version,
...(reload ? { reload } : {}),
// A number goes up as the TEXT that was typed. `2.50` stays
// `2.50` and `1.0` stays `1.0`; turning either into a JavaScript
// number here is precisely the bug the server half exists to
// avoid, and it would be reintroduced in the browser.
edits: Object.values(edits).map(({ field, value }) =>
field.type === 'number'
? { pointer: field.pointer, raw: String(value) }
: { pointer: field.pointer, value },
),
}
: { path, version: file.version, ...(reload ? { reload } : {}), text: raw }
const answer = await api.adminConfig.save(serverId, body)
setReport(answer.report || null)
if (!answer.changed) setError('Nothing changed, so nothing was written.')
// Re-read either way: a successful reload usually rewrites the file with
// the defaults it was missing, and a rollback means what is on disk is no
// longer what is on the screen.
setFileNonce((n) => n + 1)
} catch (err) {
setError(err.message || 'That save did not work.')
} finally {
setBusy(false)
}
}
const pending = Object.keys(edits).length
return (
<div style={{ maxWidth: 980 }}>
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
These are the configuration files on the game host itself, read live through the bridge. A
save backs the file up, writes it, reloads the plugin you name, and <strong>puts the old
file back automatically</strong> if the plugin does not come back. The games data
directory kit cooldowns, zone definitions, the permission store is not settings and is
never listed here.
</p>
<Card title="Server" subtitle={`${rows.length} configured`}>
<select className="input" value={serverId} onChange={(event) => setServerId(event.target.value)}>
<option value="">Choose a server</option>
{rows.map((row) => (
<option key={row.id} value={row.id}>
{row.name || row.id}
</option>
))}
</select>
{tree && tree.root && (
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
{tree.root}
{tree.truncated ? ' · the walk stopped at its limit, so this is not the whole tree' : ''}
</p>
)}
</Card>
{serverId && treeError && <ErrorState error={treeError} />}
{serverId && !treeError && !tree && <Loading />}
{tree && (
<Card title="Files" subtitle="grouped by the plugin each one probably belongs to">
{tree.plugins.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
This server reports no configuration files.
</p>
)}
{tree.plugins.map((group) => (
<div key={group.plugin} style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<strong className="sans" style={{ fontSize: '0.88rem', fontWeight: 500 }}>
{group.title || group.plugin}
</strong>
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{group.loaded ? `loaded · ${group.version}` : 'not loaded'}
{group.isBridge ? ' · this bridge' : ''}
</span>
</div>
{group.files.map((entry) => (
<div
key={entry.path}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '4px 0 4px 12px' }}
>
<button
type="button"
className={entry.path === path ? 'btn btn-primary' : 'btn btn-ghost'}
disabled={!entry.editable}
onClick={() => {
setPath(entry.path)
setReport(null)
setTier('form')
}}
>
{entry.path}
</button>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
{Math.round(entry.bytes / 102.4) / 10} KB
{entry.modified ? ` · changed ${ago(entry.modified)}` : ''}
{entry.reason ? ` · ${entry.reason}` : ''}
</span>
</div>
))}
{!group.loaded && (
<Warn>
Nothing on this server is loaded under that name, so a save here is written and
not reloaded. It applies the next time the plugin loads.
</Warn>
)}
</div>
))}
</Card>
)}
{path && fileError && <ErrorState error={fileError} />}
{path && !fileError && !file && <Loading />}
{file && (
<Card
title={file.path}
subtitle={tier === 'form' ? `${pending} unsaved` : 'raw JSON'}
actions={
<>
<button
type="button"
className={tier === 'form' ? 'btn btn-primary' : 'btn btn-ghost'}
onClick={() => setTier('form')}
>
Settings
</button>
<button
type="button"
className={tier === 'raw' ? 'btn btn-primary' : 'btn btn-ghost'}
onClick={() => setTier('raw')}
>
Raw JSON
</button>
</>
}
>
{file.parseError && (
<Warn tone="#e05a5a">
This file is not valid JSON on the server ({file.parseError}), so there is nothing to
draw a form from. Raw JSON is the tier that can fix it.
</Warn>
)}
{file.isBridge && (
<Warn>
This is the bridges own configuration. Its address, port and server id are read-only
here changing any of them from the website would cut the link carrying the change,
or strand every row this site holds for this server. They are editable on the host
itself. This plugin also cannot be reloaded from here.
</Warn>
)}
{tier === 'form' && file.fields && (
<div style={{ marginTop: 6 }}>
{file.fields
.filter((field) => field.path !== '')
.map((field) => (
<Field
key={field.path}
field={field}
value={
edits[field.path]
? edits[field.path].value
: field.type === 'number'
? field.raw
: field.value
}
onChange={change}
revealed={Boolean(revealed[field.path])}
onReveal={(p) => setRevealed((current) => ({ ...current, [p]: !current[p] }))}
/>
))}
</div>
)}
{tier === 'raw' && (
<textarea
className="input"
spellCheck={false}
value={raw}
onChange={(event) => setRaw(event.target.value)}
style={{ width: '100%', minHeight: 360, fontFamily: 'monospace', fontSize: '0.8rem' }}
/>
)}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
marginTop: 12,
borderTop: '1px solid var(--line-soft)',
paddingTop: 12,
}}
>
<label className="sans dim" style={{ fontSize: '0.78rem' }}>
Reload
</label>
{/* A guess, and it says so. The folder a config sits in is convention
rather than contract, so reloading it silently is how the wrong
plugin gets reloaded, reports success, and the edited one never
re-reads anything. */}
<select className="input" value={reload} onChange={(event) => setReload(event.target.value)}>
<option value="">nothing just write the file</option>
{(tree ? tree.loaded : [])
.filter((p) => p.name !== tree.self)
.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
</option>
))}
</select>
<span style={{ flex: 1 }} />
<button
type="button"
className="btn btn-primary"
disabled={busy || (tier === 'form' && pending === 0) || (tier === 'raw' && raw === file.text)}
onClick={save}
>
{busy ? 'Saving…' : 'Save and reload'}
</button>
</div>
{/* Beside the button, not at the top of the page. A save is made at the
bottom of a long form, and a refusal rendered above the fold is a
click that visibly did nothing. */}
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.82rem', margin: '8px 0 0' }}>
{error}
</p>
)}
<Report report={report} />
</Card>
)}
{serverId && <History serverId={serverId} nonce={fileNonce} />}
</div>
)
}
/** Who changed what, including the saves that were refused or undone. */
function History({ serverId, nonce }) {
const { data } = useAsync(() => api.adminConfig.writes(serverId), [serverId, nonce])
if (!data || !data.writes || data.writes.length === 0) return null
return (
<Card title="Recent changes" subtitle="every save, including the ones that did not land">
{data.writes.map((row) => (
<div
key={row.id}
className="sans"
style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)', fontSize: '0.82rem' }}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
<strong style={{ fontWeight: 500 }}>{row.path}</strong>
<span
className="sans"
style={{ fontSize: '0.74rem', color: row.outcome === 'applied' ? 'var(--ink)' : '#d08a2a' }}
>
{row.outcome}
{row.reloaded ? ' · reloaded' : ''}
</span>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
{ago(row.createdAt)}
{row.tier === 'raw' ? ' · raw' : ''}
</span>
</div>
{(row.changes || []).map((change, index) => (
<div key={`${row.id}-${index}`} className="dim" style={{ fontSize: '0.74rem' }}>
{change.path}
{change.from !== null && change.to !== null ? `: ${change.from}${change.to}` : ''}
</div>
))}
{row.detail && (
<div className="dim" style={{ fontSize: '0.74rem' }}>
{row.detail}
</div>
)}
</div>
))}
</Card>
)
}

View File

@@ -114,7 +114,7 @@ function ServerState({ row, onSync, busy }) {
{row.lastOkAt ? `last pushed ${ago(row.lastOkAt)}` : 'never pushed'}
</span>
<span style={{ flex: 1 }} />
<button type="button" className="btn ghost" onClick={() => onSync(row.serverId)} disabled={busy}>
<button type="button" className="btn btn-ghost" onClick={() => onSync(row.serverId)} disabled={busy}>
{busy ? 'Syncing…' : 'Sync now'}
</button>
</div>
@@ -158,10 +158,10 @@ function DriftRow({ row, onAdopt, onRevoke, busy }) {
{row.serverId} · seen {ago(row.firstSeen)}
</span>
</span>
<button type="button" className="btn ghost" onClick={() => onAdopt(row)} disabled={busy}>
<button type="button" className="btn btn-ghost" onClick={() => onAdopt(row)} disabled={busy}>
Adopt
</button>
<button type="button" className="btn ghost" onClick={() => onRevoke(row)} disabled={busy}>
<button type="button" className="btn btn-ghost" onClick={() => onRevoke(row)} disabled={busy}>
Revoke
</button>
</Row>
@@ -224,7 +224,7 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
actions={
<button
type="button"
className="btn ghost"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.deleteGroup(group.name))}
>
@@ -248,7 +248,7 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
)}
<button
type="button"
className="btn ghost"
className="btn btn-ghost"
disabled={busy}
onClick={() => save(group.permissions.filter((p) => p !== perm))}
>
@@ -317,7 +317,7 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
</span>
<button
type="button"
className="btn ghost"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.removeMember(group.name, m.userId))}
>
@@ -415,7 +415,7 @@ export default function Permissions() {
title="Servers"
subtitle={`${servers.length} configured`}
actions={
<button type="button" className="btn ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
Sync all
</button>
}
@@ -490,7 +490,7 @@ export default function Permissions() {
</span>
<button
type="button"
className="btn ghost"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.revoke(row.id))}
>

View File

@@ -83,7 +83,7 @@ function LinkPanel({ userId, link, onRemoved }) {
</div>
)}
</div>
<button type="button" className="btn ghost" onClick={unlink} disabled={busy} style={{ flex: 'none' }}>
<button type="button" className="btn btn-ghost" onClick={unlink} disabled={busy} style={{ flex: 'none' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
</div>
@@ -180,7 +180,7 @@ function PermissionsPanel({ userId, data, onChanged }) {
</span>
<button
type="button"
className="btn ghost"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(() => api.adminUserPermissions.revoke(userId, row.id))}
style={{ flex: 'none' }}

View File

@@ -122,7 +122,7 @@ function LinkRow({ link, onRemoved }) {
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>
)}
</div>
<button type="button" className="btn ghost" onClick={remove} disabled={busy} style={{ flex: 'none' }}>
<button type="button" className="btn btn-ghost" onClick={remove} disabled={busy} style={{ flex: 'none' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
</li>

View File

@@ -36,6 +36,21 @@
"path": "/api/v1/player/rust/links/:steamId",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/config/:serverId/file",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/config/:serverId/files",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/config/:serverId/writes",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/permissions",
@@ -101,6 +116,11 @@
"path": "/api/v1/public/rust/servers/:id/wipes",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/config/:serverId/file",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/drift/:id/adopt",

527
server/configEdit.js Normal file
View File

@@ -0,0 +1,527 @@
// ── Editing a plugin's config without rewriting the numbers ───────────────
//
// R18's base tier generates a form from a config file's VALUES — a boolean
// becomes a toggle, a number a field, a string a text box — so it works for
// whatever plugins an operator happens to have installed, including ones added
// after we shipped. This file is the half of that which cannot be done naively.
//
// ── The trap ──────────────────────────────────────────────────────────────
//
// **JavaScript cannot tell `1` from `1.0`.** `JSON.parse('{"Rate":1.0}')` yields
// the number `1`, and `JSON.stringify` writes it back as `1`. Both frameworks
// deserialize a config into typed C# classes, so a naive read-modify-write
// silently rewrites every whole-numbered float as an integer — **on fields
// nobody touched** — and Newtonsoft may coerce it or may throw. A throw at load
// means the plugin does not come back, and R6/R17 make four of them required.
//
// The fields at risk are exactly the ones a Rust server tunes: gather rates,
// multipliers, scales.
//
// ── So nothing here ever parses, mutates and re-serialises ────────────────
//
// `scan` is a JSON reader that records, for every value, the **span of source
// text** it came from. `applyEdits` splices new literals into those spans and
// leaves every other byte of the document exactly as it was — including the
// author's indentation, key order, and the `.0` on a float nobody edited.
//
// Two rules fall out of that and both are deliberate:
//
// 1. **A number's new value arrives as the literal text an admin typed**, never
// as a JavaScript number. `2.50` stays `2.50`; `1.0` stays `1.0`. The value
// never becomes a `Number` anywhere in this module, which is the only way to
// be sure it cannot be re-serialised into something else.
// 2. **The generated form is type-preserving.** An edit may change what a value
// IS, never what KIND of thing it is; changing a number into a string, or
// adding a key, is a structural change and belongs in the raw-JSON tier,
// where the admin is editing the document itself.
//
// Nothing in this file touches the network, a database, or core.
/** Value kinds this module names, in the language the form speaks. */
const KINDS = ['object', 'array', 'string', 'number', 'boolean', 'null']
/**
* A JSON number, by the grammar rather than by `Number()`.
*
* Used to judge a literal an admin typed. `Number('0x10')`, `Number('')` and
* `Number(' 1 ')` are all happily finite and none of the three is JSON, so the
* check has to be the grammar — which is also what keeps `1.0` and `1e3`
* acceptable, since preserving those is the entire point.
*/
const JSON_NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/
/**
* Words that make a value a secret.
*
* Matched against the key split into WORDS, not as a substring: `Monkey` and
* `Keybind` contain "key" and neither is a credential, and a config editor that
* masked every third field would teach an operator to ignore the mask.
*/
const SECRET_WORDS = new Set([
'key',
'keys',
'apikey',
'token',
'tokens',
'secret',
'secrets',
'password',
'passwd',
'pass',
'webhook',
'webhooks',
'credential',
'credentials',
'auth',
])
class JsonScanError extends Error {}
/**
* Reads `text` into a tree of nodes that remember where they came from.
*
* Every node carries `start` and `end`, the half-open span of the value in the
* source. A caller that only wants the data can read `value`; a caller that
* wants to CHANGE the data uses the span, because the span is the only thing
* that survives a round trip unchanged.
*
* @param {string} text
* @returns {object} the root node
* @throws {JsonScanError} with a position, on anything that is not JSON
*/
function scan(text) {
const src = String(text)
let at = 0
function fail(message) {
throw new JsonScanError(`${message} at offset ${at}`)
}
function ws() {
while (at < src.length && (src[at] === ' ' || src[at] === '\t' || src[at] === '\n' || src[at] === '\r')) at += 1
}
function literal(word, value) {
if (src.startsWith(word, at)) {
const start = at
at += word.length
return { type: word === 'null' ? 'null' : 'boolean', value, start, end: at }
}
return null
}
function string() {
const start = at
at += 1 // the opening quote
let out = ''
while (at < src.length) {
const ch = src[at]
if (ch === '"') {
at += 1
return { type: 'string', value: out, start, end: at }
}
if (ch === '\\') {
const esc = src[at + 1]
at += 2
if (esc === 'u') {
const hex = src.slice(at, at + 4)
if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('bad unicode escape')
out += String.fromCharCode(parseInt(hex, 16))
at += 4
} else if (esc === 'n') out += '\n'
else if (esc === 't') out += '\t'
else if (esc === 'r') out += '\r'
else if (esc === 'b') out += '\b'
else if (esc === 'f') out += '\f'
else if (esc === '"' || esc === '\\' || esc === '/') out += esc
else fail('bad escape')
continue
}
out += ch
at += 1
}
return fail('unterminated string')
}
function number() {
const start = at
if (src[at] === '-') at += 1
while (at < src.length && /[0-9]/.test(src[at])) at += 1
if (src[at] === '.') {
at += 1
while (at < src.length && /[0-9]/.test(src[at])) at += 1
}
if (src[at] === 'e' || src[at] === 'E') {
at += 1
if (src[at] === '+' || src[at] === '-') at += 1
while (at < src.length && /[0-9]/.test(src[at])) at += 1
}
const raw = src.slice(start, at)
if (!JSON_NUMBER.test(raw)) fail(`'${raw}' is not a number`)
// `raw` is the fact; `value` is a convenience for rendering and comparison,
// and is never written back to the document.
return { type: 'number', value: Number(raw), raw, start, end: at }
}
function value() {
ws()
const ch = src[at]
if (ch === '{') return object()
if (ch === '[') return array()
if (ch === '"') return string()
if (ch === '-' || (ch >= '0' && ch <= '9')) return number()
const lit = literal('true', true) || literal('false', false) || literal('null', null)
if (lit) return lit
return fail('unexpected character')
}
function object() {
const start = at
at += 1 // {
const children = []
ws()
if (src[at] === '}') {
at += 1
return { type: 'object', children, start, end: at }
}
for (;;) {
ws()
if (src[at] !== '"') fail('expected a key')
const key = string()
ws()
if (src[at] !== ':') fail('expected a colon')
at += 1
const child = value()
child.key = key.value
child.keyStart = key.start
child.keyEnd = key.end
children.push(child)
ws()
if (src[at] === ',') {
at += 1
continue
}
if (src[at] === '}') {
at += 1
return { type: 'object', children, start, end: at }
}
return fail('expected a comma or a closing brace')
}
}
function array() {
const start = at
at += 1 // [
const children = []
ws()
if (src[at] === ']') {
at += 1
return { type: 'array', children, start, end: at }
}
for (;;) {
const child = value()
child.index = children.length
children.push(child)
ws()
if (src[at] === ',') {
at += 1
continue
}
if (src[at] === ']') {
at += 1
return { type: 'array', children, start, end: at }
}
return fail('expected a comma or a closing bracket')
}
}
const root = value()
ws()
if (at !== src.length) fail('trailing content')
return root
}
/** Splits a config key into words, across camelCase, snake_case, spaces and dots. */
function words(key) {
return String(key)
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split(/[^A-Za-z0-9]+/)
.filter(Boolean)
.map((w) => w.toLowerCase())
}
/**
* Whether a key names a credential. See `SECRET_WORDS`.
*
* **A field flagged here is not emptied.** D37 decided the raw tier shows real
* values — an admin can already read the file over SSH — so the API answers with
* the document as it is, the form renders a flagged field masked with a reveal
* control, and the flag's load-bearing use is the audit trail, where the values
* genuinely never appear.
*/
function isSecretKey(key) {
return words(key).some((w) => SECRET_WORDS.has(w))
}
/** A pointer as a person reads it: `Settings.Rates[0].Wood`. */
function pointerPath(pointer) {
return pointer
.map((step) => (typeof step === 'number' ? `[${step}]` : step))
.join('.')
.replace(/\.\[/g, '[')
}
/**
* Walks a scanned tree into the flat description the form is built from.
*
* **What is NOT here is as deliberate as what is.** There are no descriptions,
* no minimums, no maximums and no allowed-value sets, because a config file
* carries none: the key name is the entire label. An empty array and a `null`
* carry no type at all, so nothing can be inferred for them and they are marked
* `advanced` — the raw tier is where a value with no shape gets edited.
*
* @param {object} root from `scan`
* @param {object} [options]
* @param {number} [options.maxDepth] past this, a subtree is advanced-only
* @param {string[]} [options.locked] top-level keys that may not be edited (D38)
*/
function describe(root, { maxDepth = 6, locked = [] } = {}) {
const lockedSet = new Set(locked.map((k) => String(k).toLowerCase()))
const fields = []
function visit(node, pointer, depth, inheritedSecret, inheritedLock) {
const key = pointer.length ? pointer[pointer.length - 1] : ''
const secret = inheritedSecret || (typeof key === 'string' && isSecretKey(key))
const isLocked =
inheritedLock || (pointer.length === 1 && typeof key === 'string' && lockedSet.has(key.toLowerCase()))
if (node.type === 'object' || node.type === 'array') {
const tooDeep = depth >= maxDepth
fields.push({
pointer: [...pointer],
path: pointerPath(pointer),
key: typeof key === 'number' ? `[${key}]` : key,
type: node.type,
depth,
count: node.children.length,
secret,
locked: isLocked,
// An empty container has nothing to infer a member's shape from, and a
// container past the depth limit has more shape than a form should try
// to draw. Both are honest reasons to send somebody to the raw tier.
advanced: tooDeep || node.children.length === 0,
...(tooDeep ? { reason: 'deeper than the form will draw' } : {}),
...(node.children.length === 0 ? { reason: 'empty, so there is no shape to read' } : {}),
})
if (tooDeep) return
node.children.forEach((child, index) => {
visit(child, [...pointer, node.type === 'array' ? index : child.key], depth + 1, secret, isLocked)
})
return
}
fields.push({
pointer: [...pointer],
path: pointerPath(pointer),
key: typeof key === 'number' ? `[${key}]` : key,
type: node.type,
depth,
// A number is reported as its LITERAL as well as its value. The literal is
// what the form must round-trip; the value is for display and sorting.
...(node.type === 'number' ? { raw: node.raw } : {}),
value: node.value,
secret,
locked: isLocked,
// `null` has no type, so there is nothing to render but a raw editor.
advanced: node.type === 'null',
...(node.type === 'null' ? { reason: 'null carries no type to read' } : {}),
})
}
visit(root, [], 0, false, false)
return fields
}
/** Finds the node a pointer names, or null. */
function resolve(root, pointer) {
let node = root
for (const step of pointer) {
if (!node || (node.type !== 'object' && node.type !== 'array')) return null
if (node.type === 'array') {
if (typeof step !== 'number') return null
node = node.children[step]
} else {
node = node.children.find((child) => child.key === step)
}
if (!node) return null
}
return node
}
/** The exact source text a node was read from. */
function literalOf(text, node) {
return String(text).slice(node.start, node.end)
}
/**
* Turns one edit into the literal that will be spliced in, or explains why not.
*
* `raw` is used verbatim for a number — that is the whole mechanism — and is
* validated against the JSON grammar first, because verbatim and unvalidated
* would be a way to write anything at all into somebody's config file.
*/
function literalFor(node, edit) {
if (node.type === 'number') {
const raw = String(edit.raw !== undefined && edit.raw !== null ? edit.raw : edit.value).trim()
if (!JSON_NUMBER.test(raw)) return { error: `'${raw}' is not a number` }
return { literal: raw }
}
if (node.type === 'string') {
if (typeof edit.value !== 'string') return { error: 'expected text' }
return { literal: JSON.stringify(edit.value) }
}
if (node.type === 'boolean') {
if (typeof edit.value !== 'boolean') return { error: 'expected true or false' }
return { literal: edit.value ? 'true' : 'false' }
}
return { error: `a ${node.type} is edited in the raw tier` }
}
/**
* Applies a set of edits to a document and returns the new text.
*
* Spans are spliced from the **end of the document backwards**, so that an
* earlier edit never moves a later edit's offsets. Every edit is resolved and
* checked before any splice happens: a refusal leaves the caller with the
* original text rather than a partly-edited one.
*
* @param {string} text
* @param {Array<{pointer: Array<string|number>, value?: any, raw?: string}>} edits
* @returns {{ text?: string, changes?: object[], error?: string }}
*/
function applyEdits(text, edits, { locked = [] } = {}) {
let root
try {
root = scan(text)
} catch (err) {
return { error: `the file on the server is not valid JSON: ${err.message}` }
}
const lockedSet = new Set(locked.map((k) => String(k).toLowerCase()))
const staged = []
const seen = new Set()
for (const edit of edits || []) {
const pointer = Array.isArray(edit.pointer) ? edit.pointer : null
if (!pointer || pointer.length === 0) return { error: 'an edit must name a field' }
const path = pointerPath(pointer)
if (seen.has(path)) return { error: `'${path}' is edited twice in one save` }
seen.add(path)
if (typeof pointer[0] === 'string' && lockedSet.has(pointer[0].toLowerCase())) {
return { error: `'${path}' cannot be edited from the website` }
}
const node = resolve(root, pointer)
if (!node) return { error: `'${path}' is not in this file` }
const { literal, error } = literalFor(node, edit)
if (error) return { error: `'${path}': ${error}` }
staged.push({
path,
pointer,
start: node.start,
end: node.end,
from: literalOf(text, node),
to: literal,
secret: pointer.some((step) => typeof step === 'string' && isSecretKey(step)),
})
}
// Nothing to do is not an error, but it must not produce a write either: a
// save with no changes would spend a reload — and a reload is the one part of
// this feature that can take a plugin down.
const changed = staged.filter((s) => s.from !== s.to)
if (changed.length === 0) return { text: String(text), changes: [] }
let out = String(text)
for (const edit of [...changed].sort((a, b) => b.start - a.start)) {
out = out.slice(0, edit.start) + edit.to + out.slice(edit.end)
}
// The result must still be JSON. It always is when the pieces are — this is a
// guard against a bug in this file, not against the caller.
try {
scan(out)
} catch (err) {
return { error: `the edit produced something that is not JSON: ${err.message}` }
}
return { text: out, changes: changed.map(redactChange) }
}
/**
* What the audit trail records for one changed field.
*
* **A secret's values are never written down.** The raw tier shows real values
* to an admin who asks for them, which is a deliberate decision (D37) about a
* page somebody has to open — but an activity log is read by more people, for
* longer, and usually by somebody who was not there. Those are different
* exposures and they get different answers.
*/
function redactChange(change) {
return {
path: change.path,
from: change.secret ? '***' : change.from,
to: change.secret ? '***' : change.to,
...(change.secret ? { secret: true } : {}),
}
}
module.exports = {
KINDS,
JSON_NUMBER,
JsonScanError,
scan,
describe,
resolve,
applyEdits,
isSecretKey,
pointerPath,
words,
}

View File

@@ -19,6 +19,9 @@
-- it knows this module registered, because it is the side that knows which
-- registrant owned what.
-- Phase 7b.
DROP TABLE IF EXISTS rust_config_writes;
-- Phase 7. Children before parents: every one of these carries a foreign key
-- into `rust_servers`, `users` or `rust_perm_groups`.
DROP TABLE IF EXISTS rust_perm_catalogue;

View File

@@ -618,3 +618,53 @@ ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS wipe_id VARCHAR(48) NULL;
-- a server is up), and `last_seen_at` is when a `server.hello` last arrived. Only
-- a successful refresh moves it.
ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS last_seen_at DATETIME NULL;
-- ── Configuration written from the site (phase 7b, R18) ───────────────────
--
-- The audit trail for the most powerful thing this website can do to somebody's
-- game host: write a file on it. One row per save attempt, including the ones
-- that were refused and the ones the plugin rolled back — a write that did not
-- land is exactly the row an operator asking "why is ZoneManager down" needs to
-- find.
--
-- **No file bodies.** `changes` holds the fields that changed and their before
-- and after LITERALS, which is what a person reading this wants, and secrets are
-- redacted on the way in (`configEdit.redactChange`). D37 lets an admin read a
-- credential on the page they opened deliberately; this table is read by more
-- people, for longer, and usually by somebody who was not there.
--
-- The versions bracket the write: `version_before` is what the plugin said the
-- file was when it was read, `version_after` what it is now. They are the
-- plugin's own hashes, echoed — this module never computes one.
CREATE TABLE IF NOT EXISTS rust_config_writes (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
server_id VARCHAR(64) NOT NULL,
path VARCHAR(255) NOT NULL,
plugin VARCHAR(128) NULL,
-- What the admin asked us to reload. NULL is an honest value: a file whose
-- plugin is not loaded is written and not reloaded, and saying so is the
-- difference between "saved" and "in effect".
reload_target VARCHAR(128) NULL,
-- `form` or `raw`. Which tier an edit came through changes how it should be
-- read: a form edit is type-preserving and narrow, a raw edit replaced the
-- whole document.
tier VARCHAR(16) NOT NULL DEFAULT 'form',
user_id INT NULL,
-- `applied` | `rolled-back` | `refused` | `unreachable`
outcome VARCHAR(24) NOT NULL,
reloaded TINYINT(1) NOT NULL DEFAULT 0,
changes LONGTEXT NULL,
version_before VARCHAR(64) NULL,
version_after VARCHAR(64) NULL,
detail VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rust_config_writes_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE,
-- A deleted account must not delete the record that they changed a setting.
-- The row stays and the name goes; the alternative is an audit trail that a
-- person can erase by closing their account.
CONSTRAINT fk_rust_config_writes_user
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
KEY idx_rust_config_writes_server (server_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -0,0 +1,79 @@
// ── The SQL half of the configuration audit ───────────────────────────────
//
// One table, two questions: record what a save did, and show an operator what
// has been done to a server lately.
//
// Nothing here talks to a game. The game half is `sidecarClient`, and the two
// are deliberately not mixed: this file is what remains true after the plugin
// has been reloaded, rolled back, or lost.
const core = require('../../core')
/**
* Records one save attempt — including the ones that never reached a file.
*
* A refusal is written for the same reason a success is: an operator asking why
* a setting is not what they set has to be able to see that somebody tried and
* was told no, and a table that only holds successes answers that question with
* silence.
*/
async function recordWrite(row) {
await core.query(
`INSERT INTO rust_config_writes
(server_id, path, plugin, reload_target, tier, user_id, outcome, reloaded,
changes, version_before, version_after, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
row.serverId,
row.path,
row.plugin || null,
row.reloadTarget || null,
row.tier || 'form',
row.userId || null,
row.outcome,
row.reloaded ? 1 : 0,
row.changes ? JSON.stringify(row.changes) : null,
row.versionBefore || null,
row.versionAfter || null,
row.detail ? String(row.detail).slice(0, 500) : null,
],
)
}
/**
* The recent history for one server, newest first.
*
* `changes` comes back parsed, and a row whose JSON will not parse comes back
* with `null` rather than throwing — a corrupt audit row must not be able to
* break the page that displays the rest of them.
*/
async function recentWrites(serverId, limit = 50) {
const rows = await core.query(
`SELECT id, server_id AS serverId, path, plugin, reload_target AS reloadTarget, tier,
user_id AS userId, outcome, reloaded, changes,
version_before AS versionBefore, version_after AS versionAfter, detail, created_at AS createdAt
FROM rust_config_writes
WHERE server_id = ?
ORDER BY id DESC
LIMIT ?`,
[serverId, Math.max(1, Math.min(Number(limit) || 50, 200))],
)
return rows.map((row) => ({
...row,
reloaded: Boolean(row.reloaded),
changes: parseChanges(row.changes),
}))
}
function parseChanges(raw) {
if (!raw) return null
try {
return JSON.parse(raw)
} catch {
return null
}
}
module.exports = { recordWrite, recentWrites, parseChanges }

View File

@@ -0,0 +1,229 @@
// ── 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$/
function isPlausiblePath(path) {
return typeof path === 'string' && path.length > 0 && path.length <= 255 && PATH_SHAPE.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) } : {}),
})
}
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)),
}
}
/** Whether this file is the bridge's own config, by the name the plugin gave. */
function isBridgeConfig(path, self) {
if (!self) return false
const plugin = String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '')
return plugin.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),
}
}
/**
* 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),
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),
})),
}
}
module.exports = {
LOCKED_KEYS,
LOCKED_REASON,
PATH_SHAPE,
isPlausiblePath,
shapeCatalogue,
shapeFile,
isBridgeConfig,
lockedKeysFor,
lockedChanges,
summariseReport,
}

View 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 servers sidecar speaks a different protocol version',
unauthorized: 'That servers sidecar rejected the stored token',
timeout: 'That servers sidecar did not answer in time',
'http-503': 'The game is not connected to that servers 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 servers 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 servers 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 }

View File

@@ -0,0 +1,108 @@
// ── 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 hosts 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 games 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 files 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

View File

@@ -32,6 +32,11 @@ const adminRustRouter = express.Router()
// do what inside the game the bridge reaches.
adminRustRouter.use('/permissions', require('./permissions.router'))
// R18's editor, under `/rust/config`. A third subject again: this router
// configures the BRIDGE, `permissions` decides who may do what inside the game,
// and this one edits the game host's own plugin settings.
adminRustRouter.use('/config', require('./config.router'))
adminRustRouter.get(
'/servers',
// #swagger.tags = ['Admin · Rust']

View File

@@ -52,11 +52,11 @@ const TIMEOUT_MS = 12000
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
* plugin, and `protocol` in its `overlay.toml`.
*
* **4the permission mirror.** Protocol 2 was the read path, 3 the first
* message the WEBSITE originates (`link.confirm`); 4 is the first that WRITES
* to the game — the whole permission set the site authors for one server, and
* the report the plugin sends back. The bump lands here in the same change as
* the emitters,
* **5configuration from the site.** Protocol 2 was the read path, 3 the
* first message the WEBSITE originates (`link.confirm`), 4 the first that
* writes to the game's permission store; 5 is the first that writes to the game
* HOST'S FILESYSTEM — a plugin's settings, and a reload watched closely enough
* to be undone. The bump lands here in the same change as the emitters,
* because the sidecar refuses a client declaring a different version with a
* `409`: a module left on 2 would stop being able to read the server board it
* has been reading all along. A constant that lags the deployment is not a safe
@@ -66,7 +66,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
const PROTOCOL_VERSION = 4
const PROTOCOL_VERSION = 5
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
@@ -239,6 +239,41 @@ const permCatalogue = (server) => request(server, '/permissions/catalogue')
*/
const permSync = (server, set) => request(server, '/permissions/sync', { method: 'POST', body: set })
/**
* Every settings file on one game host, and every plugin loaded to reload one
* (protocol 5, R18).
*
* A description of the tree, never its contents: paths, sizes, which files are
* too large to edit, and the plugin each one probably belongs to. **Probably**
* is the operative word and it survives all the way to the form — a folder name
* is convention, not contract, and reloading the wrong plugin would report
* success while the edited one never re-read anything.
*
* Live, like `/status`: what is on a host's disk has no stale answer worth
* giving, and a cached one would be an edit an operator made over SSH that the
* website then overwrote.
*/
const configFiles = (server) => request(server, '/config/files')
/** One settings file as text, with the version a write has to present back. */
const configFile = (server, path) =>
request(server, `/config/file?path=${encodeURIComponent(path)}`)
/**
* Replace a set of settings files and reload what owns them (protocol 5).
*
* **The only call in this module that writes to a filesystem**, and the only one
* whose reply routinely takes seconds: the plugin holds it open across the
* reload it is watching, and across the rollback if that reload never arrives.
*
* Like every other write on this bridge, a refusal comes back `{ ok: true }`
* with the answer in `data.kind` — `config.report` or `config.error`. The
* transport keeps its own codes, and a `504` here is the one case worth reading
* carefully: the plugin writes a whole set or restores a whole set, never half
* of either, so the state is knowable by re-reading rather than by guessing.
*/
const configWrite = (server, body) => request(server, '/config/write', { method: 'POST', body })
module.exports = {
TIMEOUT_MS,
PROTOCOL_VERSION,
@@ -252,5 +287,8 @@ module.exports = {
confirmLink,
permCatalogue,
permSync,
configFiles,
configFile,
configWrite,
joinUrl,
}

437
server/test/config.test.js Normal file
View File

@@ -0,0 +1,437 @@
// ── Configuration from the site, above the editor ─────────────────────────
//
// `configEdit.test.js` covers the bytes. This covers the decisions made around
// them, and each of these is a way the feature could look fine and be wrong:
//
// • a save that never re-reads the host writes a browser's stale copy over
// somebody else's edit;
// • the raw tier is a whole document, so a locked key can change without
// anything resembling an edit to a field (D38);
// • a rollback is a round trip that WORKED, carrying bad news, and reporting
// it as a failure throws away the only diagnosis there is;
// • a refusal that is never recorded leaves the operator asking why a setting
// is not what they set, with nothing to read.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withCore(overrides = {}) {
const queries = []
require('../core')._reset()
require('../core').init(
fakeCtx({
db: {
query: (sql, params) => {
queries.push({ sql: sql.trim().replace(/\s+/g, ' '), params })
const verb = sql.trim().split(/\s+/)[0].toUpperCase()
if (verb === 'SELECT') return Promise.resolve([])
return Promise.resolve({ affectedRows: 1, insertId: 1 })
},
pool: {},
},
...overrides,
}),
)
return queries
}
/** A response double that records what a controller decided. */
function fakeRes() {
const res = { statusCode: 200, body: null }
res.status = (code) => {
res.statusCode = code
return res
}
res.json = (body) => {
res.body = body
return res
}
return res
}
const FILE = `{
"Gather": { "Wood": 1.0 },
"Enabled": true
}`
const OWN = `{
"Host": "127.0.0.1",
"Port": 7799,
"QueueCap": 5000,
"ServerId": "main"
}`
/** Stubs the four calls this controller can make, and records them. */
function stubSidecar({ file = FILE, self = 'RunicGateway', write } = {}) {
const sidecar = require('../sidecarClient')
const calls = []
sidecar.configFile = async (server, asked) => {
calls.push(['read', asked])
const text = asked === 'RunicGateway.json' ? OWN : file
return {
ok: true,
status: 'ok',
data: { kind: 'config.file', path: asked, text, version: `v-${asked}`, bytes: text.length },
}
}
sidecar.configFiles = async () => {
calls.push(['list'])
return {
ok: true,
status: 'ok',
data: {
kind: 'config.catalogue',
root: '/home/container/oxide/config',
self,
files: [
{ path: 'ZoneManager.json', bytes: 40, editable: true, plugin: 'ZoneManager' },
{ path: 'RunicGateway.json', bytes: 90, editable: true, plugin: 'RunicGateway' },
{ path: 'Huge.json', bytes: 9e6, editable: false, reason: 'larger than this bridge will carry', plugin: 'Huge' },
],
plugins: [{ name: 'ZoneManager', title: 'Zone Manager', version: '3.1.14' }],
truncated: false,
},
}
}
sidecar.configWrite = async (server, body) => {
calls.push(['write', body])
return write || { ok: true, status: 'ok', data: { kind: 'config.report', ok: true, reloaded: true, files: [{ path: body.files[0].path, version: 'v-after' }] } }
}
return calls
}
/** The controller's own server lookup, satisfied without a database. */
function stubServer(row = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://x', sidecarTokenEnc: null, protocol: 5 }) {
const serversDb = require('../model/servers/servers.db')
const servers = require('../model/servers/servers.model')
serversDb.getServer = async () => row
servers.withToken = () => (row ? { id: row.id, baseUrl: row.sidecarBaseUrl, token: 't' } : null)
}
function controller() {
return require('../router/admin/config.controller')
}
test('the catalogue is grouped by plugin, and an unloaded one is marked rather than dropped', async () => {
withCore()
stubSidecar()
stubServer()
const res = fakeRes()
await controller().listFiles({ params: { serverId: 'main' }, query: {} }, res)
const zone = res.body.plugins.find((p) => p.plugin === 'ZoneManager')
const bridge = res.body.plugins.find((p) => p.plugin === 'RunicGateway')
const huge = res.body.plugins.find((p) => p.plugin === 'Huge')
assert.equal(zone.loaded, true)
assert.equal(zone.version, '3.1.14')
// Not loaded, still listed. A config that vanished from the page would read
// as "the bridge cannot see it", which is a much more alarming problem than
// the true one.
assert.equal(huge.loaded, false)
assert.equal(huge.files[0].editable, false)
assert.ok(huge.files[0].reason)
// The bridge's own config is named as such, because it is the one plugin that
// cannot be reloaded from here.
assert.equal(bridge.isBridge, true)
assert.equal(res.body.root, '/home/container/oxide/config')
})
test('a save re-reads the host and refuses a stale version with the current file', async () => {
withCore()
stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: { path: 'ZoneManager.json', version: 'v-stale', edits: [{ pointer: ['Enabled'], value: false }] },
},
res,
)
assert.equal(res.statusCode, 409)
// The current file comes back, so a person can merge their change rather than
// retype it from memory.
assert.equal(res.body.current.version, 'v-ZoneManager.json')
assert.match(res.body.message, /changed on the server/)
})
test('a form edit is spliced into what is on disk NOW, and sent as whole text', async () => {
withCore()
const calls = stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
reload: 'ZoneManager',
edits: [{ pointer: ['Enabled'], value: false }],
},
},
res,
)
const [, body] = calls.find((c) => c[0] === 'write')
assert.equal(body.files.length, 1)
assert.equal(body.reload, 'ZoneManager')
assert.equal(body.files[0].version, 'v-ZoneManager.json')
assert.match(body.files[0].text, /"Enabled": false/)
// The untouched float, which is the entire reason this path exists.
assert.match(body.files[0].text, /"Wood": 1\.0/)
assert.equal(res.statusCode, 200)
assert.equal(res.body.changed, true)
assert.equal(res.body.report.reloaded, true)
})
test('a save that changes nothing does not reach the game at all', async () => {
withCore()
const calls = stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
edits: [{ pointer: ['Enabled'], value: true }],
},
},
res,
)
assert.equal(res.body.changed, false)
// A write would have spent a reload, and a reload is the one part of this
// feature that can take a plugin down.
assert.equal(calls.some((c) => c[0] === 'write'), false)
})
test('the raw tier cannot change a locked key, even though it sends a whole document (D38)', async () => {
withCore()
const calls = stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'RunicGateway.json',
version: 'v-RunicGateway.json',
text: OWN.replace('7799', '9999'),
},
},
res,
)
assert.equal(res.statusCode, 400)
assert.match(res.body.message, /Port/)
assert.equal(calls.some((c) => c[0] === 'write'), false)
// And the rest of our own config is still editable, which is the half of D38
// that is easy to lose.
const ok = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'RunicGateway.json',
version: 'v-RunicGateway.json',
text: OWN.replace('5000', '9000'),
},
},
ok,
)
assert.equal(ok.statusCode, 200)
assert.equal(ok.body.changed, true)
})
test('a rollback is a 200 carrying bad news, and the log line survives to the admin', async () => {
const queries = withCore()
stubSidecar({
write: {
ok: true,
status: 'ok',
data: {
kind: 'config.report',
ok: false,
reloaded: false,
rolledBack: true,
reason: "'ZoneManager' did not reload within 4s",
log: 'Error while compiling ZoneManager: expected , at line 14',
files: [{ path: 'ZoneManager.json', version: 'v-restored' }],
},
},
})
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
reload: 'ZoneManager',
edits: [{ pointer: ['Enabled'], value: false }],
},
},
res,
)
assert.equal(res.statusCode, 200)
assert.equal(res.body.report.rolledBack, true)
assert.match(res.body.report.log, /line 14/)
const audit = queries.find((q) => q.sql.includes('INSERT INTO rust_config_writes'))
assert.ok(audit, 'a rollback must be recorded')
assert.ok(audit.params.includes('rolled-back'))
})
test('a refusal from the game is recorded too, with its own status', async () => {
const queries = withCore()
stubSidecar({
write: { ok: true, status: 'ok', data: { kind: 'config.error', reason: 'reload-self' } },
})
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
reload: 'RunicGateway',
edits: [{ pointer: ['Enabled'], value: false }],
},
},
res,
)
assert.equal(res.statusCode, 400)
assert.match(res.body.message, /cannot be reloaded from the website/)
const audit = queries.find((q) => q.sql.includes('INSERT INTO rust_config_writes'))
assert.ok(audit && audit.params.includes('refused'), 'a refusal is part of the audit trail')
})
test('a server that cannot be reached answers 503 with the reason, and records the attempt', async () => {
const queries = withCore()
stubSidecar({ write: { ok: false, status: 'http-503', data: null } })
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
edits: [{ pointer: ['Enabled'], value: false }],
},
},
res,
)
assert.equal(res.statusCode, 503)
assert.match(res.body.message, /not connected/)
assert.ok(queries.find((q) => q.sql.includes('INSERT INTO rust_config_writes')))
})
test('a path that could not have come from the host is refused before any round trip', async () => {
withCore()
const calls = stubSidecar()
stubServer()
for (const path of ['../oxide/data/oxide.users.data', '/etc/passwd', 'C:/x.json', 'notjson.txt', '']) {
const res = fakeRes()
await controller().readFile({ params: { serverId: 'main' }, query: { path } }, res)
assert.equal(res.statusCode, 400, `${path} should be refused`)
}
assert.equal(calls.length, 0, 'nothing malformed should cost a round trip')
// And the shapes a host really does list.
for (const path of ['Kits.json', 'Kits/kits.json', 'My Mod/sub dir/file.json']) {
assert.equal(require('../model/config/config.model').isPlausiblePath(path), true, path)
}
})
test('the audit trail records which fields changed, and never a credential', async () => {
const queries = withCore()
stubSidecar({ file: '{\n "Discord Webhook": "https://hooks/1",\n "Enabled": true\n}' })
stubServer()
await controller().writeFile(
{
params: { serverId: 'main' },
user: { id: 7 },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
edits: [{ pointer: ['Discord Webhook'], value: 'https://hooks/2' }],
},
},
fakeRes(),
)
const audit = queries.find((q) => q.sql.includes('INSERT INTO rust_config_writes'))
const changes = audit.params.find((p) => typeof p === 'string' && p.startsWith('['))
assert.match(changes, /Discord Webhook/)
assert.ok(!changes.includes('hooks/1'), 'the old credential must not be recorded')
assert.ok(!changes.includes('hooks/2'), 'the new credential must not be recorded')
assert.ok(audit.params.includes(7), 'the person who did it is recorded')
})
test('a file that is already broken on disk still opens, in the tier that can fix it', () => {
const model = require('../model/config/config.model')
const shaped = model.shapeFile(
{ path: 'Broken.json', text: '{ "a": }', version: 'v1', bytes: 8 },
{ self: 'RunicGateway' },
)
assert.equal(shaped.fields, null)
assert.ok(shaped.parseError, 'the reason it cannot be drawn is part of the answer')
assert.equal(shaped.text, '{ "a": }')
})
test('the bridges own file is recognised by the plugins name, not by a filename we matched', () => {
const model = require('../model/config/config.model')
assert.equal(model.isBridgeConfig('RunicGateway.json', 'RunicGateway'), true)
assert.equal(model.isBridgeConfig('RunicGateway/extra.json', 'RunicGateway'), true)
assert.equal(model.isBridgeConfig('ZoneManager.json', 'RunicGateway'), false)
// Renamed on the host: the lock follows the plugin, which is the only thing
// that knows what it is called.
assert.equal(model.isBridgeConfig('Bridge.json', 'Bridge'), true)
assert.deepEqual(model.lockedKeysFor('Bridge.json', 'Bridge'), model.LOCKED_KEYS)
// And a host that said nothing about itself locks nothing, rather than
// locking everything or guessing.
assert.deepEqual(model.lockedKeysFor('RunicGateway.json', null), [])
})

View File

@@ -0,0 +1,236 @@
// ── The editor that must not touch what it was not asked to ───────────────
//
// `configEdit.js` exists for one reason: a config file goes back to the game
// host byte-identical except where an admin deliberately changed something. So
// the suite is mostly about what does NOT change, and the first test is the one
// the whole design is for.
//
// It is worth being concrete about the failure being prevented. `Rate: 1.0` in
// an untouched field, read through `JSON.parse` and written back through
// `JSON.stringify`, becomes `Rate: 1`. Newtonsoft may coerce that into a
// `float` or may throw; if it throws, the plugin does not come back from its
// reload — and R6/R17 make four plugins required, so "ZoneManager is down" is
// also "event participation is down".
const test = require('node:test')
const assert = require('node:assert')
const configEdit = require('../configEdit')
/** A config with every shape that has ever caused trouble. */
const SAMPLE = `{
"Gather": {
"Wood": 1.0,
"Stone": 2.50,
"Sulfur": 3,
"Scale": 1e3
},
"Enabled": true,
"Message": "Welcome, {name}",
"Discord Webhook": "https://discord.com/api/webhooks/1/abc",
"Zones": ["a", "b"],
"Nothing": null,
"Empty": []
}`
test('an untouched float keeps its literal — the whole point of this file', () => {
const { text, changes } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Enabled'], value: false }])
assert.equal(changes.length, 1)
assert.match(text, /"Wood": 1\.0/)
assert.match(text, /"Stone": 2\.50/)
assert.match(text, /"Scale": 1e3/)
assert.match(text, /"Enabled": false/)
// And the proof that the naive implementation would have failed this: the same
// document through parse/stringify loses all three.
const naive = JSON.stringify(JSON.parse(SAMPLE))
assert.match(naive, /"Wood":1,/)
assert.doesNotMatch(naive, /2\.50/)
assert.doesNotMatch(naive, /1e3/)
})
test('a number is written as the literal an admin typed, not as a Number', () => {
const { text } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Gather', 'Wood'], raw: '2.0' }])
assert.match(text, /"Wood": 2\.0/)
// The same edit through a JavaScript number would have produced `2`, which is
// a different C# type at the far end.
assert.equal(String(2.0), '2')
})
test('everything else in the document is byte-identical', () => {
const { text } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Gather', 'Sulfur'], raw: '4' }])
const before = SAMPLE.split('\n')
const after = text.split('\n')
assert.equal(before.length, after.length)
before.forEach((line, i) => {
if (line.includes('"Sulfur"')) return
assert.equal(after[i], line, `line ${i + 1} changed and should not have`)
})
})
test('a literal that is not a JSON number is refused', () => {
for (const raw of ['0x10', '', ' ', '1.', '.5', '01', 'NaN', 'Infinity', '1,0', '5; rm -rf /']) {
const { error, text } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Gather', 'Sulfur'], raw }])
assert.ok(error, `'${raw}' should be refused`)
assert.equal(text, undefined)
}
// And the ones that must keep working, because preserving them is the point.
for (const raw of ['1.0', '-2', '1e3', '1E-3', '0', '0.5', '123456789012345678']) {
const { error } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Gather', 'Sulfur'], raw }])
assert.equal(error, undefined, `'${raw}' should be accepted`)
}
})
test('the form cannot change a value KIND — that is the raw tier', () => {
assert.match(
configEdit.applyEdits(SAMPLE, [{ pointer: ['Enabled'], value: 'yes' }]).error,
/true or false/,
)
assert.match(configEdit.applyEdits(SAMPLE, [{ pointer: ['Message'], value: 7 }]).error, /expected text/)
assert.match(configEdit.applyEdits(SAMPLE, [{ pointer: ['Zones'], value: 'a' }]).error, /raw tier/)
assert.match(configEdit.applyEdits(SAMPLE, [{ pointer: ['Nothing'], value: 1 }]).error, /raw tier/)
})
test('a string is escaped on the way in', () => {
const { text } = configEdit.applyEdits(SAMPLE, [
{ pointer: ['Message'], value: 'He said "hi"\nand left\\' },
])
assert.match(text, /"Message": "He said \\"hi\\"\\nand left\\\\"/)
// Still JSON, and still the same string coming back out.
assert.equal(JSON.parse(text).Message, 'He said "hi"\nand left\\')
})
test('a pointer that is not in the file is refused rather than created', () => {
assert.match(configEdit.applyEdits(SAMPLE, [{ pointer: ['Nope'], value: true }]).error, /not in this file/)
assert.match(
configEdit.applyEdits(SAMPLE, [{ pointer: ['Gather', 'Wood', 'Deeper'], raw: '1' }]).error,
/not in this file/,
)
})
test('an edit set that changes nothing writes nothing', () => {
const { text, changes } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Enabled'], value: true }])
assert.equal(text, SAMPLE)
assert.deepEqual(changes, [])
})
test('two edits to the same field in one save are refused', () => {
const { error } = configEdit.applyEdits(SAMPLE, [
{ pointer: ['Gather', 'Wood'], raw: '1.0' },
{ pointer: ['Gather', 'Wood'], raw: '2.0' },
])
assert.match(error, /edited twice/)
})
test('several edits land together, and later offsets are not shifted by earlier ones', () => {
const { text, changes } = configEdit.applyEdits(SAMPLE, [
{ pointer: ['Gather', 'Wood'], raw: '10.0' },
{ pointer: ['Message'], value: 'much longer than it was before' },
{ pointer: ['Zones', 1], value: 'bb' },
])
assert.equal(changes.length, 3)
const parsed = JSON.parse(text)
assert.equal(parsed.Gather.Wood, 10)
assert.equal(parsed.Message, 'much longer than it was before')
assert.deepEqual(parsed.Zones, ['a', 'bb'])
assert.match(text, /"Wood": 10\.0/)
})
test('a locked key cannot be edited, and the refusal names it (D38)', () => {
const own = '{\n "Host": "127.0.0.1",\n "Port": 7799,\n "QueueCap": 5000\n}'
const locked = ['Host', 'Port', 'ServerId']
assert.match(configEdit.applyEdits(own, [{ pointer: ['Port'], raw: '1' }], { locked }).error, /Port/)
assert.match(
configEdit.applyEdits(own, [{ pointer: ['Host'], value: '10.0.0.5' }], { locked }).error,
/cannot be edited/,
)
// Everything else in the bridge's own config stays editable, which is the
// half of D38 that is easy to lose.
const { text } = configEdit.applyEdits(own, [{ pointer: ['QueueCap'], raw: '9000' }], { locked })
assert.match(text, /"QueueCap": 9000/)
})
test('a secret is flagged by WORD, not by substring', () => {
for (const key of ['ApiKey', 'Discord Webhook', 'steam_api_key', 'Token', 'Password', 'authToken']) {
assert.equal(configEdit.isSecretKey(key), true, `${key} should be a secret`)
}
// The false positives a substring match would produce, and they matter: a
// form that masks a third of every config teaches an operator to ignore the
// mask, which is worse than not masking.
for (const key of ['Monkey', 'Keybind', 'Passive Mode', 'Authority', 'Keycards Allowed']) {
assert.equal(configEdit.isSecretKey(key), false, `${key} should not be a secret`)
}
// A genuinely ambiguous one, resolved toward masking on purpose: a field
// called `Keys` is a credential often enough, and the cost of being wrong is
// a field an admin has to click to read rather than a credential on a page.
assert.equal(configEdit.isSecretKey('Keys'), true)
})
test("a secret's values never reach the audit trail, though the change is recorded", () => {
const { changes } = configEdit.applyEdits(SAMPLE, [
{ pointer: ['Discord Webhook'], value: 'https://discord.com/api/webhooks/2/def' },
])
assert.equal(changes.length, 1)
assert.equal(changes[0].path, 'Discord Webhook')
assert.equal(changes[0].from, '***')
assert.equal(changes[0].to, '***')
assert.equal(changes[0].secret, true)
})
test('the form description says which fields it cannot draw, and why', () => {
const fields = configEdit.describe(configEdit.scan(SAMPLE))
const by = (path) => fields.find((f) => f.path === path)
assert.equal(by('Gather.Wood').type, 'number')
assert.equal(by('Gather.Wood').raw, '1.0')
assert.equal(by('Enabled').type, 'boolean')
assert.equal(by('Zones[0]').value, 'a')
assert.equal(by('Discord Webhook').secret, true)
// The three things a value cannot tell us anything about.
assert.equal(by('Nothing').advanced, true)
assert.equal(by('Empty').advanced, true)
assert.ok(by('Nothing').reason)
assert.ok(by('Empty').reason)
})
test('a subtree past the depth limit is advanced-only rather than half-drawn', () => {
const deep = '{"a":{"b":{"c":{"d":{"e":{"f":{"g":1}}}}}}}'
const fields = configEdit.describe(configEdit.scan(deep), { maxDepth: 3 })
const past = fields.find((f) => f.path === 'a.b.c')
assert.equal(past.advanced, true)
assert.equal(fields.some((f) => f.path.startsWith('a.b.c.')), false)
})
test('a document that is not JSON is refused with a position', () => {
assert.throws(() => configEdit.scan('{"a": }'), /offset/)
assert.throws(() => configEdit.scan('{"a": 1,}'), /expected a key/)
assert.throws(() => configEdit.scan('{} trailing'), /trailing content/)
assert.throws(() => configEdit.scan('{"a": "unterminated'), /unterminated/)
const { error } = configEdit.applyEdits('{ not json', [{ pointer: ['a'], value: true }])
assert.match(error, /not valid JSON/)
})
test('escapes and unicode survive a scan of a document nobody edited', () => {
const text = '{"a":"tab\\there","b":"\\u00e9\\u0041","c":"slash\\/"}'
const root = configEdit.scan(text)
const values = Object.fromEntries(root.children.map((c) => [c.key, c.value]))
assert.deepEqual(values, JSON.parse(text))
})

View File

@@ -1,5 +1,163 @@
{
"paths": {
"/api/v1/admin/rust/config/{serverId}/file": {
"get": {
"tags": [
"Admin · Rust"
],
"summary": "One configuration file",
"description": "The files 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.",
"parameters": [
{
"name": "serverId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "path",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The file and the reading of it"
},
"400": {
"description": "Not a configuration path"
},
"503": {
"description": "The sidecar or the game is unreachable"
}
}
},
"post": {
"tags": [
"Admin · Rust"
],
"summary": "Save a configuration file and reload its plugin",
"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.",
"parameters": [
{
"name": "serverId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "What happened: applied, or rolled back with the reason"
},
"400": {
"description": "Invalid body, an edit the form may not make, or a locked key"
},
"409": {
"description": "The file changed on the host since it was read"
},
"503": {
"description": "The sidecar or the game is unreachable"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"path": {
"example": "any"
},
"edits": {
"example": "any"
},
"reload": {
"example": "any"
},
"version": {
"example": "any"
},
"text": {
"example": "any"
}
}
}
}
}
}
}
},
"/api/v1/admin/rust/config/{serverId}/files": {
"get": {
"tags": [
"Admin · Rust"
],
"summary": "Every plugin configuration file on one server",
"description": "A live recursive walk of the game hosts 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 games data directory is never walked.",
"parameters": [
{
"name": "serverId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The configuration tree and the loaded plugins"
},
"502": {
"description": "Bad Gateway"
},
"503": {
"description": "The sidecar or the game is unreachable"
}
}
}
},
"/api/v1/admin/rust/config/{serverId}/writes": {
"get": {
"tags": [
"Admin · Rust"
],
"summary": "Recent configuration writes",
"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.",
"parameters": [
{
"name": "serverId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "limit",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The recent writes, newest first"
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/admin/rust/permissions": {
"get": {
"tags": [