Four leases on core.lease: rust.decay.scale, rust.population, rust.spawn.scalar and rust.group.permission. Every lease is targeted and the target names the server (D73). Also the three target option sources plus rust.options.servers, with no budgets (D79). Held for up to seven days (D77). A key that is already held reads as its baseline. Drift is an answer, not a failure. inForce reads the plugin's holds and never compares values. Lease calls get a 4.5s timeout so that two of them fit in core.lease's 10s budget, and a timed-out apply is followed by a release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
357 lines
16 KiB
JavaScript
357 lines
16 KiB
JavaScript
// ── The near end of a call whose far end is a Rust server ─────────────────
|
|
//
|
|
// Every other file in this module reads its own tables. This one is different in
|
|
// kind: it is the only place that leaves the process.
|
|
//
|
|
// **The website process never opens a connection to a game server**
|
|
// (MODULE_API.md §2.7). It opens one to a `rust-link` sidecar, which owns the
|
|
// socket to the game, persists what the game says before forwarding it, and
|
|
// answers reads from that store. `test/noGameConnection.test.js` enforces the
|
|
// decidable half of that rule and names this file as the one that may reach the
|
|
// network:
|
|
//
|
|
// const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js'])
|
|
//
|
|
// ── One client per configured server ──────────────────────────────────────
|
|
//
|
|
// R8: the bridge is one game server to one sidecar. So this file takes the
|
|
// server row as an argument rather than holding a single configured endpoint —
|
|
// six servers is six base URLs and six tokens, and core never learns there is
|
|
// more than one.
|
|
//
|
|
// ── TIMEOUT_MS is not a tuning knob. It is half of a rule. ────────────────
|
|
//
|
|
// An event action declares `budgetMs`, and core's dispatcher enforces it: when
|
|
// the budget expires it stops waiting and classifies the failure as **retry**,
|
|
// unconditionally, without asking the action — it cannot ask, the action is still
|
|
// awaiting a socket. So if core's deadline is shorter than this one, an action
|
|
// never gets to classify its own failure and `{ ok: false, retry: false }` is
|
|
// unreachable code. `budgetMs` must EXCEED this.
|
|
//
|
|
// It is also bounded from the other side: the sidecar's own RPC reply timeout is
|
|
// ten seconds, so a value below that would give up while the sidecar is still
|
|
// legitimately waiting for the game. The ordering is
|
|
// `sidecar RPC timeout < TIMEOUT_MS < budgetMs`, and every one of the three
|
|
// is written down somewhere the other two can be checked against.
|
|
//
|
|
// ── This file never throws ────────────────────────────────────────────────
|
|
//
|
|
// Every call answers `{ ok, status, data }`. A module that let a socket failure
|
|
// escape into a controller would hand an exception to a page whose whole job is
|
|
// to render while the game is off. The public site degrades; it does not 500.
|
|
|
|
const core = require('./core')
|
|
|
|
const log = core.logger('sidecar')
|
|
|
|
/** How long this client waits before giving up on a sidecar. See the header. */
|
|
const TIMEOUT_MS = 12000
|
|
|
|
/**
|
|
* The wire version this module speaks. Declared in FOUR places that must agree:
|
|
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
|
|
* plugin, and `protocol` in its `overlay.toml`.
|
|
*
|
|
* **8 — the leases.** 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 the first that writes to the game HOST'S
|
|
* FILESYSTEM; 6 adds the `clans` board and five clan events core's Teams are
|
|
* built from, and no route at all; **7** widens `entity.destroyed` to doors,
|
|
* walls and the cupboard and names who is authorised there, which is what the
|
|
* raid alert is sent to (PLAN.md §25); **8** adds the leases — `GET /lease`,
|
|
* `POST /lease` and `POST /lease/release` — which is what lets an event borrow
|
|
* a value on a server and give it back (PLAN.md §27). 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
|
|
* default; it is an outage with a version number on it.
|
|
*
|
|
* It is sent on every request as `X-RustLink-Version`, which turns a mismatched
|
|
* deployment into a `409` naming both numbers instead of a parse failure three
|
|
* layers further in.
|
|
*/
|
|
const PROTOCOL_VERSION = 8
|
|
|
|
/** What a caller gets back. Shaped once so every call site reads the same. */
|
|
function reply(ok, status, data = null) {
|
|
return { ok, status, data }
|
|
}
|
|
|
|
/**
|
|
* Normalises a configured base URL into something `new URL(path, base)` will not
|
|
* surprise anybody with.
|
|
*
|
|
* A trailing slash on the base and a leading slash on the path is the classic
|
|
* way to lose a path segment, and an operator pasting a URL out of a terminal
|
|
* supplies the trailing slash about half the time.
|
|
*/
|
|
function joinUrl(baseUrl, path) {
|
|
return `${String(baseUrl).replace(/\/+$/, '')}${path}`
|
|
}
|
|
|
|
/**
|
|
* One request to one sidecar.
|
|
*
|
|
* @param {object} server a `rust_servers` row, token already decrypted
|
|
* @param {string} server.baseUrl
|
|
* @param {string|null} server.token
|
|
* @param {string} path e.g. `/server`
|
|
* @param {object} [options]
|
|
* @param {string} [options.method]
|
|
* @param {object} [options.body]
|
|
* @param {number} [options.timeoutMs] shorter than `TIMEOUT_MS` only where a
|
|
* caller has a tighter budget of its own to fit inside — see `LEASE_TIMEOUT_MS`
|
|
*/
|
|
async function request(server, path, { method = 'GET', body = null, timeoutMs = TIMEOUT_MS } = {}) {
|
|
if (!server || !server.baseUrl) return reply(false, 'not-configured')
|
|
|
|
// A sidecar with auth off does not exist — it generates and persists a token on
|
|
// first start — so a missing token here is a half-finished admin form, not a
|
|
// sidecar to try unauthenticated. Saying so beats a 401 the operator has to
|
|
// interpret.
|
|
if (!server.token) return reply(false, 'no-token')
|
|
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
|
|
try {
|
|
const res = await fetch(joinUrl(server.baseUrl, path), {
|
|
method,
|
|
signal: controller.signal,
|
|
headers: {
|
|
Authorization: `Bearer ${server.token}`,
|
|
'X-RustLink-Version': String(PROTOCOL_VERSION),
|
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
},
|
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
})
|
|
|
|
// A protocol mismatch is a deployment fault and deserves its own status, not
|
|
// to be folded into "the sidecar said no". The operator's fix is an upgrade
|
|
// of one component, and the message has to be able to say which.
|
|
if (res.status === 409) {
|
|
const detail = await safeJson(res)
|
|
log.warn('protocol mismatch', {
|
|
server: server.id,
|
|
module: PROTOCOL_VERSION,
|
|
sidecar: detail && detail.sidecar_protocol,
|
|
})
|
|
return reply(false, 'protocol-mismatch', detail)
|
|
}
|
|
|
|
if (res.status === 401) return reply(false, 'unauthorized')
|
|
|
|
// 204 is an ANSWER, not an absence of one: the sidecar is up and reports that
|
|
// the game has never connected. Collapsing it into a failure would make a
|
|
// freshly installed server indistinguishable from an unreachable one.
|
|
if (res.status === 204) return reply(true, 'empty', null)
|
|
|
|
if (!res.ok) return reply(false, `http-${res.status}`)
|
|
|
|
return reply(true, 'ok', await safeJson(res))
|
|
} catch (err) {
|
|
// `AbortError` is this client's own deadline firing, and it is worth telling
|
|
// apart from a refused connection: one means the sidecar is slow or the game
|
|
// is not answering, the other means nothing is listening.
|
|
const status = err && err.name === 'AbortError' ? 'timeout' : 'transport-error'
|
|
log.warn('sidecar request failed', { server: server.id, path, status, error: err.message })
|
|
return reply(false, status)
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
async function safeJson(res) {
|
|
try {
|
|
return await res.json()
|
|
} catch {
|
|
// A sidecar that answered 200 with something that is not JSON is a sidecar
|
|
// this module cannot use, but it is not a reason to throw at a page.
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Liveness, the protocol version, and whether the plugin is connected. Unauthenticated at the far end, but sent authenticated anyway so one code path covers every call. */
|
|
const health = (server) => request(server, '/health')
|
|
|
|
/** The last `server.hello` the sidecar stored. Answers while the game is off. */
|
|
const serverBoard = (server) => request(server, '/server')
|
|
|
|
/** A live round trip through the sidecar to the game. Fails when the game is down, by design. */
|
|
const liveStatus = (server) => request(server, '/status')
|
|
|
|
/** Every board at once: what is true now, before following what happens next. */
|
|
const boards = (server) => request(server, '/boards')
|
|
|
|
/**
|
|
* The ingest cursor: events after `since`, oldest first.
|
|
*
|
|
* **`since` is required here, unlike on the wire.** The sidecar treats an omitted
|
|
* cursor as "tell me where the end is", which is a genuinely useful question and
|
|
* a catastrophic default for an ingest loop that would silently store nothing
|
|
* and advance past everything. So the question is asked explicitly, by name, and
|
|
* a caller cannot get it by forgetting an argument.
|
|
*/
|
|
const feed = (server, since, limit = 200) =>
|
|
request(server, `/feed?since=${encodeURIComponent(since)}&limit=${encodeURIComponent(limit)}`)
|
|
|
|
/** Where the sidecar's history currently ends. What a new server's cursor starts at. */
|
|
const feedTail = (server) => request(server, '/feed')
|
|
|
|
/**
|
|
* Redeem a one-time link code against one server (protocol 3).
|
|
*
|
|
* **The only call in this file that is not a GET**, and the only one that asks
|
|
* the game a question rather than reading what it already said. The sidecar
|
|
* forwards the code to the plugin, which holds the pending codes in memory, and
|
|
* hands back what it answers.
|
|
*
|
|
* **A refused code comes back `{ ok: true }`.** `link.ok` and `link.error` are
|
|
* both answers — the sidecar reserves its own failures for the transport (503
|
|
* when the game is down, 504 when it is up and silent) — and the caller has to
|
|
* tell "that code is wrong" from "the game never replied" to say the right thing
|
|
* to a player. So the discrimination happens on `data.kind`, not on `ok`.
|
|
*
|
|
* A code is spent on the plugin's FIRST lookup whether or not it turns out to be
|
|
* expired, so this must never be called speculatively for its answer alone.
|
|
*/
|
|
const confirmLink = (server, code) =>
|
|
request(server, '/link/confirm', { method: 'POST', body: { code } })
|
|
|
|
/**
|
|
* What one server's loaded plugins have registered, and the groups its store
|
|
* holds (protocol 4).
|
|
*
|
|
* The option source behind the authoring form (D33). It is a live read through
|
|
* to the game rather than anything cached at the sidecar, because the answer
|
|
* changes when an operator loads a plugin — and the whole reason to ask is to
|
|
* offer names that will actually resolve. It therefore fails when the game is
|
|
* down, like `/status` and unlike every store-backed read.
|
|
*/
|
|
const permCatalogue = (server) => request(server, '/permissions/catalogue')
|
|
|
|
/**
|
|
* Push the whole permission set this site authors for one server (protocol 4).
|
|
*
|
|
* **The second call in this file that is not a GET, and the first that changes
|
|
* the game.** The body is the desired set plus what the site has withdrawn; the
|
|
* plugin diffs it against the live store, applies the difference and answers
|
|
* with a report — counts, the names it could not resolve, the memberships that
|
|
* are waiting on a first connection, and every holder the site did not author.
|
|
*
|
|
* **A refusal comes back `{ ok: true }`**, like a refused link code: `perm.error`
|
|
* and `perm.report` are both answers, and the sidecar keeps its own status codes
|
|
* for the transport. The caller discriminates on `data.kind`.
|
|
*/
|
|
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 })
|
|
|
|
/**
|
|
* How long ONE lease call waits — shorter than every other call here, and for
|
|
* the same rule `TIMEOUT_MS` is written for, applied to a caller with a tighter
|
|
* budget.
|
|
*
|
|
* A lease is taken by core's `core.lease`, which declares no `budgetMs` and so
|
|
* runs under the dispatcher's default of 10 seconds — and inside that it makes
|
|
* TWO calls into this module, `read()` for the baseline and then `apply()`. At
|
|
* `TIMEOUT_MS` each, one slow read would let the dispatcher give up and call the
|
|
* attempt a retry while the module is still waiting, which is the ordering the
|
|
* header of this file exists to forbid. Two of these fit inside core's budget
|
|
* with a second to spare, and `leases.test.js` asserts the arithmetic rather than
|
|
* trusting it.
|
|
*
|
|
* It is below the sidecar's own ten-second reply timeout, so this end can give
|
|
* up on a call the game is still going to answer. For `read` that costs nothing.
|
|
* For `apply` it would leave a value held that core believes it never took — so
|
|
* `eventLeases.js` follows a timed-out apply with a release (§27.3).
|
|
*/
|
|
const LEASE_TIMEOUT_MS = 4500
|
|
|
|
/** The dispatcher's default action budget, which is what `core.lease` runs under. Mirrored, not imported: core does not export it. */
|
|
const CORE_LEASE_BUDGET_MS = 10000
|
|
|
|
/**
|
|
* What one server lends, what it holds now, and every hold in force
|
|
* (protocol 8). Narrowed to one key and target when given.
|
|
*/
|
|
const leaseList = (server, { key, target } = {}) => {
|
|
const q = []
|
|
if (key) q.push(`key=${encodeURIComponent(key)}`)
|
|
if (target) q.push(`target=${encodeURIComponent(target)}`)
|
|
return request(server, `/lease${q.length ? `?${q.join('&')}` : ''}`, { timeoutMs: LEASE_TIMEOUT_MS })
|
|
}
|
|
|
|
/**
|
|
* Borrow a value (protocol 8). Like every write on this bridge, a refusal
|
|
* comes back `{ ok: true }` with `data.kind` of `lease.error`; the transport
|
|
* keeps its own codes.
|
|
*/
|
|
const leaseApply = (server, body) =>
|
|
request(server, '/lease', { method: 'POST', body, timeoutMs: LEASE_TIMEOUT_MS })
|
|
|
|
/**
|
|
* Give a value back — compare-and-set at the far end. `data.kind` is
|
|
* `lease.ok`, `lease.drifted` (a 200: the plugin compared and declined to
|
|
* overwrite somebody's change) or `lease.error`.
|
|
*/
|
|
const leaseRelease = (server, body) =>
|
|
request(server, '/lease/release', { method: 'POST', body, timeoutMs: LEASE_TIMEOUT_MS })
|
|
|
|
module.exports = {
|
|
TIMEOUT_MS,
|
|
LEASE_TIMEOUT_MS,
|
|
CORE_LEASE_BUDGET_MS,
|
|
PROTOCOL_VERSION,
|
|
request,
|
|
health,
|
|
serverBoard,
|
|
liveStatus,
|
|
boards,
|
|
feed,
|
|
feedTail,
|
|
confirmLink,
|
|
permCatalogue,
|
|
permSync,
|
|
configFiles,
|
|
configFile,
|
|
configWrite,
|
|
leaseList,
|
|
leaseApply,
|
|
leaseRelease,
|
|
joinUrl,
|
|
}
|