Add RCON Data Bridge server-side logic: request pipeline, idempotency, player snapshots, world stats, and operation registry
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user