// ── The logic half ──────────────────────────────────────────────────────── // // Shapes what the database returned into what a client should see, and holds the // one rule that matters most in this module: **what leaves this file is never the // sidecar's credential.** // // It is a separate file from the SQL so that it is testable without a database, // and the suite next door tests it that way. // // The other decision worth pointing at: **a module answers when the game is // unreachable rather than failing.** The website is the internet-facing process // and the game is not; a game being down, or a sidecar being mid-restart, is an // ordinary Tuesday. A page that renders "offline, last seen 20 minutes ago" is // right; a page that 500s because a socket is closed is a module that has made // the site's availability depend on the game's. const core = require('../../core') const db = require('./servers.db') const log = core.logger('servers') // Past this, the last thing a server said stops being news and starts being // history. Presentation, so the number lives with the code that shapes the // response rather than in the client. const STALE_AFTER_MS = 5 * 60 * 1000 /** * A configured server with its token decrypted, for this module's own use. * * **Never hand the result of this to a controller.** It is the input to * `sidecarClient`, and the only shape in this module that holds a plaintext * secret. * * A token that will not decrypt is returned as `null` rather than throwing: the * usual cause is a `SECRET_ENC_KEY` that changed, and the right behaviour is a * server that reports itself unconfigured with a line in the log — not a module * that fails to boot and takes every other server down with it. */ function withToken(row) { if (!row) return null let token = null if (row.sidecarTokenEnc) { try { token = core.secretBox().decrypt(row.sidecarTokenEnc) } catch (err) { log.error('could not decrypt a sidecar token', { server: row.id, error: err.message }) } } return { id: row.id, name: row.name, baseUrl: row.sidecarBaseUrl, token, protocol: row.protocol } } /** Every enabled server, with tokens, for the poller. */ async function listForPolling() { const rows = await db.listServers({ enabledOnly: true }) return rows.map(withToken) } /** * The public view: every enabled server and what it last said. * * Nothing here is conditional on who is asking, which is the point of it being * the public shape. What a *player* or an *admin* additionally sees is added by * their own tier's controller, never removed by this one. */ async function listPublic(now = Date.now()) { const [servers, states] = await Promise.all([db.listServers({ enabledOnly: true }), db.listState()]) const byId = new Map(states.map((s) => [s.serverId, s])) return servers.map((row) => shapePublic(row, byId.get(row.id), now)) } function shapePublic(row, state, now) { const updatedAt = state && state.updatedAt ? new Date(state.updatedAt) : null const lastSeenAt = state && state.lastSeenAt ? new Date(state.lastSeenAt) : null const stale = !updatedAt || now - updatedAt.getTime() > STALE_AFTER_MS return { id: row.id, name: row.name, // A stale row cannot claim a server is up. The row says what was true when it // was written, and nothing has written it since. online: Boolean(state && state.online) && !stale, players: stale ? 0 : Number(state && state.players) || 0, maxPlayers: Number(state && state.maxPlayers) || 0, hostname: (state && state.hostname) || null, level: (state && state.level) || null, worldSize: state && state.worldSize != null ? Number(state.worldSize) : null, seed: state && state.seed != null ? Number(state.seed) : null, // The CURRENT wipe, from the state row rather than from the newest row in // `rust_wipes`. The two usually agree and the state row is the one that is // right when they do not: a wipe list is derived from events that have been // ingested, so a server that has just wiped and said nothing since has a new // wipe id here and no row there at all. wipeId: (state && state.wipeId) || null, wipedAt: (state && state.saveCreatedAt) || null, // Two timestamps, because they are two facts. `lastSeenAt` is when a frame // last arrived and is what a page means by "last reported"; `updatedAt` is // when this module last wrote the row, and is what `stale` is computed from. // Reading the second as the first is what made an offline server claim it had // reported just now, on every failed poll, for as long as it stayed down. lastSeenAt: lastSeenAt ? lastSeenAt.toISOString() : null, updatedAt: updatedAt ? updatedAt.toISOString() : null, stale, } } /** * One enabled server, or `null`. * * It exists because `/rust/servers/:id` is a page and a page needs to be able to * 404. A detail view built by fetching the list and finding the row in it cannot * tell "no such server" from "a server that has said nothing" — both are an * absence — and renders an empty page under a heading for a server that does not * exist. Filtering happens here, where `enabled = 0` and "never configured" are * the same answer on purpose: a disabled server is not a 403, it is not there. */ async function getPublic(id, now = Date.now()) { if (!id) return null const row = await db.getServer(id) if (!row || !row.enabled) return null return shapePublic(row, await db.getState(row.id), now) } /** * The admin view: configuration plus reachability, and **no token**. * * `hasToken` rather than the token, because the credential is write-only in the * API: the admin form accepts a new value and never shows the stored one. An * operator still needs to know whether one is set — a blank field means both * "unset" and "set, and not being shown you" otherwise. */ async function listForAdmin(now = Date.now()) { const [servers, states] = await Promise.all([db.listServers(), db.listState()]) const byId = new Map(states.map((s) => [s.serverId, s])) return servers.map((row) => { const state = byId.get(row.id) return { // The public shape first, so the admin-only fields below cannot be // overwritten by a key the public shape happens to share. ...shapePublic(row, state, now), sidecarBaseUrl: row.sidecarBaseUrl, hasToken: Boolean(row.sidecarTokenEnc), protocol: Number(row.protocol), enabled: Boolean(row.enabled), sortOrder: Number(row.sortOrder), reachable: Boolean(state && state.reachable), bootId: (state && state.bootId) || null, sidecarProtocol: state && state.protocol != null ? Number(state.protocol) : null, } }) } /** Encrypt a token for storage. `null`/empty means "leave whatever is stored alone". */ function encryptToken(token) { if (token === null || token === undefined || token === '') return null return core.secretBox().encrypt(String(token)) } module.exports = { STALE_AFTER_MS, withToken, listForPolling, listPublic, getPublic, listForAdmin, shapePublic, encryptToken, }