diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpPlayerGet.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpPlayerGet.lua new file mode 100644 index 0000000..342df36 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpPlayerGet.lua @@ -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 diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpPlayerList.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpPlayerList.lua new file mode 100644 index 0000000..afdf048 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpPlayerList.lua @@ -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 diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpServerSave.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpServerSave.lua new file mode 100644 index 0000000..02d7fdf --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpServerSave.lua @@ -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 diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpWorldGetStats.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpWorldGetStats.lua new file mode 100644 index 0000000..eb37e2e --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpWorldGetStats.lua @@ -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 diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Bootstrap.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Bootstrap.lua new file mode 100644 index 0000000..02aec97 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Bootstrap.lua @@ -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) diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Idempotency.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Idempotency.lua new file mode 100644 index 0000000..01cb8e8 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Idempotency.lua @@ -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 diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_OpRegistry.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_OpRegistry.lua new file mode 100644 index 0000000..e5d4824 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_OpRegistry.lua @@ -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 diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_PlayerSnapshot.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_PlayerSnapshot.lua new file mode 100644 index 0000000..29d3bd7 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_PlayerSnapshot.lua @@ -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_ 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 diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_RequestPipeline.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_RequestPipeline.lua new file mode 100644 index 0000000..8470122 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_RequestPipeline.lua @@ -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 diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Security.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Security.lua new file mode 100644 index 0000000..d60f00e --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Security.lua @@ -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 diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_WorldStats.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_WorldStats.lua new file mode 100644 index 0000000..f0f43e2 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_WorldStats.lua @@ -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