Add RCON Data Bridge server-side logic: request pipeline, idempotency, player snapshots, world stats, and operation registry

This commit is contained in:
2026-08-26 14:45:57 +02:00
parent 96b8e6263e
commit 19376c3efa
11 changed files with 697 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
-- Registration deferred to register(), called from RDB_Bootstrap's
-- onServerStarted, see RDB_OpWorldGetStats.lua.
RDB_OpPlayerGet = {}
function RDB_OpPlayerGet.register()
RDB_OpRegistry.register("player.get", {
argsSchema = { id = "string" },
handler = function(args)
local snapshot = RDB_PlayerSnapshot.get(args.id)
if not snapshot then
return false, RDB_Constants.ERROR_CODES.PLAYER_NOT_FOUND,
"No registered player matched the supplied id."
end
RDB_PlayerSnapshot.publishStatsOption(args.id)
return true, snapshot
end,
})
end

View File

@@ -0,0 +1,48 @@
-- Cursor-based pagination (spec: "Large responses use pagination or
-- cursor-based retrieval"): players are sorted by id, and the cursor is the
-- last id returned in the previous page.
--
-- Registration deferred to register(), called from RDB_Bootstrap's
-- onServerStarted -- see RDB_OpWorldGetStats.lua for why.
RDB_OpPlayerList = {}
local DEFAULT_LIMIT = 50
local MAX_LIMIT = 200
function RDB_OpPlayerList.register()
RDB_OpRegistry.register("player.list", {
handler = function(args)
local all = RDB_PlayerSnapshot.listAll()
local limit = DEFAULT_LIMIT
if type(args.limit) == "number" then
limit = math.min(math.max(1, math.floor(args.limit)), MAX_LIMIT)
end
local startIndex = 1
if type(args.cursor) == "string" and args.cursor ~= "" then
for i, snapshot in ipairs(all) do
if snapshot.id > args.cursor then
startIndex = i
break
end
startIndex = i + 1
end
end
local page = {}
local endIndex = math.min(startIndex + limit - 1, #all)
for i = startIndex, endIndex do
page[#page + 1] = all[i]
end
local nextCursor = nil
if endIndex < #all then
nextCursor = page[#page].id
end
return true, { players = page, nextCursor = nextCursor }
end,
})
end

View File

@@ -0,0 +1,16 @@
-- Registration deferred to register(), called from RDB_Bootstrap's
-- onServerStarted, see RDB_OpWorldGetStats.lua.
RDB_OpServerSave = {}
function RDB_OpServerSave.register()
RDB_OpRegistry.register("server.save", {
handler = function(_)
local ok = pcall(function() GameWindow.save(true) end)
if not ok then
return false, RDB_Constants.ERROR_CODES.SAVE_FAILED, "World save failed."
end
return true, { triggered = true }
end,
})
end

View File

@@ -0,0 +1,16 @@
-- Registration is deferred to RDB_OpRegisterAll(), called from
-- RDB_Bootstrap's onServerStarted, rather than run at file top-level:
-- PZ's require() is best-effort (warns and continues rather than forcing a
-- synchronous load), so RDB_OpRegistry may not be defined yet if this file's
-- top-level code ran immediately at auto-load time. onServerStarted fires
-- only after every file has auto-loaded, so it's safe there.
RDB_OpWorldGetStats = {}
function RDB_OpWorldGetStats.register()
RDB_OpRegistry.register("world.get_stats", {
handler = function(_)
return true, RDB_WorldStats.collect()
end,
})
end

View File

@@ -0,0 +1,73 @@
require("RconDataBridge.RDB_Constants")
require("RconDataBridge.RDB_Log")
require("RconDataBridge.RDB_Json")
require("RconDataBridge.RDB_Base64")
require("RconDataBridge.RDB_OptionsRegistry")
require("RconDataBridge.RDB_Config")
require("RconDataBridge.RDB_OpRegistry")
require("RconDataBridge.RDB_RequestPipeline")
require("RconDataBridge.RDB_WorldStats")
require("RconDataBridge.RDB_PlayerSnapshot")
require("RconDataBridge.Ops.RDB_OpWorldGetStats")
require("RconDataBridge.Ops.RDB_OpPlayerGet")
require("RconDataBridge.Ops.RDB_OpPlayerList")
require("RconDataBridge.Ops.RDB_OpServerSave")
local function runSelfTests()
if not RDB_Constants.DEBUG then
return
end
local ok, err = RDB_Json.selfTest()
if ok then
RDB_Log.info("RDB_Json self-test passed")
else
RDB_Log.error("RDB_Json self-test FAILED: " .. tostring(err))
end
local b64Ok, b64Err = RDB_Base64.selfTest()
if b64Ok then
RDB_Log.info("RDB_Base64 self-test passed")
else
RDB_Log.error("RDB_Base64 self-test FAILED: " .. tostring(b64Err))
end
end
local function registerBaseOptions()
local OPT = RDB_Constants.OPT
RDB_OptionsRegistry.register(OPT.PROTOCOL_VERSION, "{}", RDB_Constants.OPTION_MAX_LENGTH)
RDB_OptionsRegistry.register(OPT.WORLD_STATS, "{}", RDB_Constants.OPTION_MAX_LENGTH)
RDB_OptionsRegistry.register(OPT.REQUEST, "{}", RDB_Constants.OPTION_MAX_LENGTH)
RDB_OptionsRegistry.register(OPT.RESPONSE, "{}", RDB_Constants.OPTION_MAX_LENGTH)
RDB_OptionsRegistry.register(OPT.LAST_RESPONSE, "0", RDB_Constants.OPTION_MAX_LENGTH)
end
local function registerOps()
RDB_OpWorldGetStats.register()
RDB_OpPlayerGet.register()
RDB_OpPlayerList.register()
RDB_OpServerSave.register()
end
local function publishProtocolVersion()
local json = RDB_Json.encode({
version = RDB_Constants.PROTOCOL_VERSION,
ops = RDB_OpRegistry.listNames(),
})
RDB_OptionsRegistry.set(RDB_Constants.OPT.PROTOCOL_VERSION, json)
end
local function onServerStarted()
runSelfTests()
RDB_Config.init()
registerBaseOptions()
registerOps()
publishProtocolVersion()
RDB_RequestPipeline.init()
RDB_WorldStats.init()
RDB_PlayerSnapshot.init()
RDB_Log.info("bootstrap complete")
end
Events.OnServerStarted.Add(onServerStarted)

View File

@@ -0,0 +1,61 @@
-- Processed request-id -> cached response, so a repeated request id replays
-- the original response instead of re-executing the op.
-- Persisted via ModData so it survives restarts.
require("RconDataBridge.RDB_Log")
RDB_Idempotency = {}
local TAG = "RconDataBridge_Idempotency"
local MAX_ENTRIES = 500
local store
local function ensureStore()
if not store then
store = ModData.getOrCreate(TAG)
end
return store
end
function RDB_Idempotency.has(id)
return ensureStore()[id] ~= nil
end
function RDB_Idempotency.get(id)
local entry = ensureStore()[id]
if not entry then
return nil
end
return entry.response
end
local function prune(s)
local n = 0
for _ in pairs(s) do
n = n + 1
end
if n <= MAX_ENTRIES then
return
end
local entries = {}
for id, entry in pairs(s) do
entries[#entries + 1] = { id = id, ts = entry.ts or 0 }
end
table.sort(entries, function(a, b) return a.ts < b.ts end)
local toRemove = n - MAX_ENTRIES
for i = 1, toRemove do
s[entries[i].id] = nil
end
end
function RDB_Idempotency.put(id, response)
local s = ensureStore()
s[id] = { response = response, ts = getTimestampMs() }
prune(s)
end

View File

@@ -0,0 +1,59 @@
-- Table-driven op registry. This is the allowlist: dispatch only ever looks
-- up a handler function by exact op name in this table -- there is no
-- dynamic _G[name]() call and no load()/loadstring() anywhere in this mod.
require("RconDataBridge.RDB_Constants")
RDB_OpRegistry = {}
local handlers = {}
-- def: { handler = function(args) -> ok, dataOrErrCode[, errMessage], argsSchema = {fieldName = "type", ...} }
function RDB_OpRegistry.register(name, def)
handlers[name] = def
end
function RDB_OpRegistry.isRegistered(name)
return handlers[name] ~= nil
end
function RDB_OpRegistry.listNames()
local names = {}
for name in pairs(handlers) do
names[#names + 1] = name
end
table.sort(names)
return names
end
-- Validates args against the op's declared schema (shallow: field presence +
-- Lua type of top-level fields), then dispatches. Returns:
-- ok, data on success
-- false, errCode, errMessage on failure (schema or handler-reported error)
function RDB_OpRegistry.dispatch(name, args)
local def = handlers[name]
if not def then
return false, RDB_Constants.ERROR_CODES.OP_NOT_ALLOWED, "Unknown or disallowed operation: " .. tostring(name)
end
args = args or {}
if def.argsSchema then
for field, expectedType in pairs(def.argsSchema) do
local value = args[field]
if value == nil then
return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Missing required arg: " .. field
end
if type(value) ~= expectedType then
return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR,
"Arg " .. field .. " must be " .. expectedType
end
end
end
local ok, dataOrCode, message = def.handler(args)
if not ok then
return false, dataOrCode, message
end
return true, dataOrCode
end

View File

@@ -0,0 +1,155 @@
-- Normalized player snapshots: id (username), online, characterId,
-- lastSeenAt, stats{hoursSurvived, zombiesKilled}.
-- Persisted via ModData so offline lookups work, there is no
-- engine-provided offline-player registry API (checked exhaustively
-- against the stub trees), so this snapshot store is the only source
-- of truth for offline players..
--
-- Connect/disconnect detection: no server-side connect/disconnect event was
-- found in the stub trees, so this polls getOnlinePlayers() on the same
-- tick throttle as the request pipeline and diffs against the last-known
-- online set.
require("RconDataBridge.RDB_Constants")
require("RconDataBridge.RDB_Log")
require("RconDataBridge.RDB_Json")
require("RconDataBridge.RDB_OptionsRegistry")
RDB_PlayerSnapshot = {}
local TAG = "RconDataBridge_PlayerSnapshots"
local store
local function ensureStore()
if not store then
store = ModData.getOrCreate(TAG)
end
return store
end
local function buildStatsFromPlayer(player)
return {
hoursSurvived = player:getHoursSurvived(),
zombiesKilled = player:getZombieKills(),
}
end
-- Refreshes (or creates) the snapshot for an online IsoPlayer.
function RDB_PlayerSnapshot.refresh(player)
local username = player:getUsername()
local s = ensureStore()
s[username] = {
id = username,
online = true,
characterId = player:getOnlineID(),
lastSeenAt = getTimestampMs(),
stats = buildStatsFromPlayer(player),
}
end
-- Marks a previously-online player as offline, keeping their last known
-- stats and characterId.
function RDB_PlayerSnapshot.markOffline(username)
local s = ensureStore()
local existing = s[username]
if not existing then
return
end
existing.online = false
existing.lastSeenAt = getTimestampMs()
end
function RDB_PlayerSnapshot.get(id)
return ensureStore()[id]
end
-- RconDataBridge_PlayerStats_<ID> options are registered lazily, only for
-- players actually queried, not eagerly for the whole roster: ServerOptions
-- has no observed removeOption, so eager per-player registration would
-- grow unboundedly and bloat every RCON client's showoptions output
-- for a server with any real player history.
-- Not restored across a restart, it's simply re-created the next time
-- that player is queried.
function RDB_PlayerSnapshot.publishStatsOption(id)
local snapshot = ensureStore()[id]
if not snapshot then
return
end
local optionName = RDB_Constants.PLAYER_STATS_PREFIX .. id:gsub("[^%w_]", "_")
RDB_OptionsRegistry.register(optionName, "{}", RDB_Constants.OPTION_MAX_LENGTH)
RDB_OptionsRegistry.set(optionName, RDB_Json.encode(snapshot))
end
function RDB_PlayerSnapshot.listAll()
local out = {}
for _, snapshot in pairs(ensureStore()) do
out[#out + 1] = snapshot
end
table.sort(out, function(a, b) return a.id < b.id end)
return out
end
-- Death hook: refresh immediately so the final stats (e.g. zombiesKilled at
-- time of death) are captured rather than waiting for the next periodic
-- sweep.
function RDB_PlayerSnapshot.onCharacterDeath(character)
if character == nil or character.getUsername == nil then
return -- not a player character
end
local ok = pcall(RDB_PlayerSnapshot.refresh, character)
if not ok then
return
end
end
local knownOnline = {}
local function pollOnlineRoster()
local currentOnline = {}
local players = getOnlinePlayers()
for i = 0, players:size() - 1 do
local player = players:get(i)
local username = player:getUsername()
currentOnline[username] = true
RDB_PlayerSnapshot.refresh(player)
end
for username in pairs(knownOnline) do
if not currentOnline[username] then
RDB_PlayerSnapshot.markOffline(username)
end
end
knownOnline = currentOnline
end
local tickCounter = 0
local initialized = false
local function onTick()
tickCounter = tickCounter + 1
if tickCounter < RDB_Constants.POLL_EVERY_TICKS then
return
end
tickCounter = 0
local ok, err = pcall(pollOnlineRoster)
if not ok then
RDB_Log.error("error polling online roster: " .. tostring(err))
end
end
function RDB_PlayerSnapshot.init()
if initialized then
return
end
initialized = true
Events.OnTick.Add(onTick)
Events.OnCharacterDeath.Add(RDB_PlayerSnapshot.onCharacterDeath)
end

View File

@@ -0,0 +1,151 @@
-- Polls RconDataBridge_Request for changes (there is no "option changed"
-- event in the PZ Lua API) and runs the full request/response pipeline.
-- Every outcome, including every rejection, is audited.
require("RconDataBridge.RDB_Constants")
require("RconDataBridge.RDB_Json")
require("RconDataBridge.RDB_Base64")
require("RconDataBridge.RDB_Log")
require("RconDataBridge.RDB_OptionsRegistry")
require("RconDataBridge.RDB_Security")
require("RconDataBridge.RDB_Idempotency")
require("RconDataBridge.RDB_AuditLog")
require("RconDataBridge.RDB_OpRegistry")
RDB_RequestPipeline = {}
local lastSeenRaw = nil
local tickCounter = 0
local function buildOkResponse(id, data)
return {
v = RDB_Constants.PROTOCOL_VERSION,
id = id,
ok = true,
data = data or {}
}
end
local function buildErrorResponse(id, code, message)
return {
v = RDB_Constants.PROTOCOL_VERSION,
id = id,
ok = false,
error = { code = code, message = message },
}
end
local function publishResponse(response)
RDB_OptionsRegistry.set(RDB_Constants.OPT.RESPONSE, RDB_Json.encode(response))
RDB_OptionsRegistry.set(RDB_Constants.OPT.LAST_RESPONSE, tostring(getTimestampMs()))
end
local function audit(id, op, ok, code)
RDB_AuditLog.record({ id = id, op = op, ok = ok, code = code })
end
-- Handles one raw request string end to end. raw is base64: RCON's
-- changeoption command strips every literal double-quote character from
-- every argument (see RESEARCH_LOG.md), so a client can't send raw JSON --
-- base64 has no quote characters in its alphabet and survives intact.
local function processRequest(rawBase64)
local raw, b64Err = RDB_Base64.decode(rawBase64)
if b64Err then
local response = buildErrorResponse("", RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Invalid base64: " .. b64Err)
publishResponse(response)
audit("", "", false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR)
return
end
local sizeOk, sizeCode, sizeMsg = RDB_Security.checkPayloadSize(raw)
if not sizeOk then
local response = buildErrorResponse("", sizeCode, sizeMsg)
publishResponse(response)
audit("", "", false, sizeCode)
return
end
local decoded, decodeErr = RDB_Json.decode(raw)
if decodeErr then
local response = buildErrorResponse("", RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Invalid JSON: " .. decodeErr)
publishResponse(response)
audit("", "", false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR)
return
end
-- Empty object ({}) is the option's rest state, not a real request.
if type(decoded) == "table" and decoded.v == nil and decoded.id == nil and decoded.op == nil then
return
end
local envelopeOk, envelopeCode, envelopeMsg = RDB_Security.validateEnvelope(decoded)
if not envelopeOk then
local id = (type(decoded) == "table" and type(decoded.id) == "string") and decoded.id or ""
local response = buildErrorResponse(id, envelopeCode, envelopeMsg)
publishResponse(response)
audit(id, (type(decoded) == "table" and decoded.op) or "", false, envelopeCode)
return
end
local id, op, args = decoded.id, decoded.op, decoded.args
if RDB_Idempotency.has(id) then
local cached = RDB_Idempotency.get(id)
publishResponse(cached)
audit(id, op, cached.ok, cached.ok and nil or (cached.error and cached.error.code))
return
end
local rateOk, rateCode, rateMsg = RDB_Security.checkRateLimit()
if not rateOk then
local response = buildErrorResponse(id, rateCode, rateMsg)
publishResponse(response)
audit(id, op, false, rateCode)
return
end
local dispatchOk, dataOrCode, message = RDB_OpRegistry.dispatch(op, args)
local response
if dispatchOk then
response = buildOkResponse(id, dataOrCode)
else
response = buildErrorResponse(id, dataOrCode, message)
end
RDB_Idempotency.put(id, response)
publishResponse(response)
audit(id, op, dispatchOk, dispatchOk and nil or dataOrCode)
end
local function onTick()
tickCounter = tickCounter + 1
if tickCounter < RDB_Constants.POLL_EVERY_TICKS then
return
end
tickCounter = 0
local raw = RDB_OptionsRegistry.get(RDB_Constants.OPT.REQUEST)
if raw == lastSeenRaw then
return
end
lastSeenRaw = raw
local ok, err = pcall(processRequest, raw)
if not ok then
RDB_Log.error("unhandled error processing request: " .. tostring(err))
end
end
local initialized = false
-- Guarded: Events.OnServerStarted fires more than once per boot (see
-- RESEARCH_LOG.md), and Events.OnTick.Add does not deduplicate identical
-- listeners, so calling this twice would otherwise double-register onTick.
function RDB_RequestPipeline.init()
if initialized then
return
end
initialized = true
lastSeenRaw = RDB_OptionsRegistry.get(RDB_Constants.OPT.REQUEST)
Events.OnTick.Add(onTick)
end

View File

@@ -0,0 +1,63 @@
-- Payload size cap, envelope schema validation, and rate limiting.
--
-- Rate limiting is necessarily global (not per-caller): RCON requests carry
-- no per-connection identity in this transport -- every RCON client shares
-- the same RconDataBridge_Request mailbox. The window is an in-memory sliding
-- log; it does not need to survive a restart.
require("RconDataBridge.RDB_Constants")
require("RconDataBridge.RDB_Config")
RDB_Security = {}
function RDB_Security.checkPayloadSize(raw)
if #raw > RDB_Config.get("maxPayloadBytes") then
return false, RDB_Constants.ERROR_CODES.PAYLOAD_TOO_LARGE, "Request payload exceeds size limit"
end
return true
end
-- Requires: v (number, ==PROTOCOL_VERSION), id (non-empty string), op (non-empty string),
-- args (table or nil).
function RDB_Security.validateEnvelope(obj)
if type(obj) ~= "table" then
return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Request must be a JSON object"
end
if obj.v ~= RDB_Constants.PROTOCOL_VERSION then
return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Unsupported or missing protocol version"
end
if type(obj.id) ~= "string" or #obj.id == 0 or #obj.id > 128 then
return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Missing or invalid id"
end
if type(obj.op) ~= "string" or #obj.op == 0 then
return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Missing or invalid op"
end
if obj.args ~= nil and type(obj.args) ~= "table" then
return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "args must be an object when present"
end
return true
end
local requestTimestamps = {}
function RDB_Security.checkRateLimit()
local now = getTimestampMs()
local windowMs = RDB_Config.get("rateLimitWindowSeconds") * 1000
local maxRequests = RDB_Config.get("rateLimitMaxRequests")
local kept = {}
for _, ts in ipairs(requestTimestamps) do
if now - ts < windowMs then
kept[#kept + 1] = ts
end
end
requestTimestamps = kept
if #requestTimestamps >= maxRequests then
return false, RDB_Constants.ERROR_CODES.RATE_LIMITED, "Too many requests, slow down"
end
requestTimestamps[#requestTimestamps + 1] = now
return true
end

View File

@@ -0,0 +1,35 @@
-- World telemetry.
require("RconDataBridge.RDB_Constants")
require("RconDataBridge.RDB_Log")
require("RconDataBridge.RDB_Json")
require("RconDataBridge.RDB_OptionsRegistry")
RDB_WorldStats = {}
local cached = { playerCount = 0, generatedAt = 0 }
function RDB_WorldStats.collect()
return cached
end
local function refresh()
cached = {
playerCount = getNumActivePlayers(),
generatedAt = getTimestampMs(),
}
RDB_OptionsRegistry.set(RDB_Constants.OPT.WORLD_STATS, RDB_Json.encode(cached))
end
local initialized = false
function RDB_WorldStats.init()
if initialized then
return
end
initialized = true
refresh()
Events.EveryOneMinute.Add(refresh)
end