From 439105ec6023cc3eee2b2b7195807f317996c733 Mon Sep 17 00:00:00 2001 From: Overlord Date: Thu, 27 Aug 2026 10:28:52 +0200 Subject: [PATCH 1/2] Add new RCON operations for audit log retrieval and bridge status reporting, extend rate limit tracking, and improve response handling --- .../RconDataBridge/Ops/RDB_OpAuditLogGet.lua | 35 +++++++++++++++++++ .../RconDataBridge/Ops/RDB_OpBridgeStatus.lua | 21 +++++++++++ .../server/RconDataBridge/RDB_Bootstrap.lua | 15 ++++++++ .../lua/server/RconDataBridge/RDB_Config.lua | 13 +++++++ .../RconDataBridge/RDB_RequestPipeline.lua | 14 ++++++-- .../server/RconDataBridge/RDB_Security.lua | 24 ++++++++++--- 6 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpAuditLogGet.lua create mode 100644 Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpBridgeStatus.lua diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpAuditLogGet.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpAuditLogGet.lua new file mode 100644 index 0000000..39fb5a5 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpAuditLogGet.lua @@ -0,0 +1,35 @@ +-- Returns the most recent audit entries, newest first. RDB_AuditLog.getEntries() +-- is a ring buffer in slot order, not chronological order once it's wrapped +-- past its capacity, so entries are sorted by ts before slicing. +-- +-- Registration deferred to register(), called from RDB_Bootstrap's +-- onServerStarted, see RDB_OpWorldGetStats.lua. + +RDB_OpAuditLogGet = {} + +local DEFAULT_LIMIT = 50 +local MAX_LIMIT = 200 + +function RDB_OpAuditLogGet.register() + RDB_OpRegistry.register("get_audit_log", { + handler = function(args) + local limit = DEFAULT_LIMIT + if type(args.limit) == "number" then + limit = math.min(math.max(1, math.floor(args.limit)), MAX_LIMIT) + end + + local entries = {} + for _, entry in ipairs(RDB_AuditLog.getEntries()) do + entries[#entries + 1] = entry + end + table.sort(entries, function(a, b) return (a.ts or 0) > (b.ts or 0) end) + + local page = {} + for i = 1, math.min(limit, #entries) do + page[#page + 1] = entries[i] + end + + return true, { entries = page } + end, + }) +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpBridgeStatus.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpBridgeStatus.lua new file mode 100644 index 0000000..66a21e8 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpBridgeStatus.lua @@ -0,0 +1,21 @@ +-- Registration deferred to register(), called from RDB_Bootstrap's +-- onServerStarted, see RDB_OpWorldGetStats.lua. + +RDB_OpBridgeStatus = {} + +function RDB_OpBridgeStatus.register() + RDB_OpRegistry.register("get_bridge_status", { + handler = function(_) + local bootedAt = RDB_Bootstrap.getBootTimestamp() + local now = getTimestampMs() + + return true, { + version = RDB_Constants.PROTOCOL_VERSION, + bootedAt = bootedAt, + uptimeSeconds = (now - bootedAt) / 1000, + rateLimit = RDB_Security.getRateLimitStatus(), + config = RDB_Config.getAll(), + } + 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 index 02aec97..576a4b9 100644 --- a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Bootstrap.lua +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Bootstrap.lua @@ -12,6 +12,16 @@ require("RconDataBridge.Ops.RDB_OpWorldGetStats") require("RconDataBridge.Ops.RDB_OpPlayerGet") require("RconDataBridge.Ops.RDB_OpPlayerList") require("RconDataBridge.Ops.RDB_OpServerSave") +require("RconDataBridge.Ops.RDB_OpBridgeStatus") +require("RconDataBridge.Ops.RDB_OpAuditLogGet") + +RDB_Bootstrap = {} + +local bootTimestamp + +function RDB_Bootstrap.getBootTimestamp() + return bootTimestamp +end local function runSelfTests() if not RDB_Constants.DEBUG then @@ -47,6 +57,8 @@ local function registerOps() RDB_OpPlayerGet.register() RDB_OpPlayerList.register() RDB_OpServerSave.register() + RDB_OpBridgeStatus.register() + RDB_OpAuditLogGet.register() end local function publishProtocolVersion() @@ -59,6 +71,9 @@ local function publishProtocolVersion() end local function onServerStarted() + if not bootTimestamp then + bootTimestamp = getTimestampMs() + end runSelfTests() RDB_Config.init() registerBaseOptions() diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Config.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Config.lua index a8b2cd8..29c542f 100644 --- a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Config.lua +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Config.lua @@ -43,3 +43,16 @@ function RDB_Config.set(key, value) store[key] = value end + +-- Shallow copy, so a caller can't mutate live config through the returned table. +function RDB_Config.getAll() + if not store then + RDB_Config.init() + end + + local copy = {} + for key, value in pairs(store) do + copy[key] = value + end + return copy +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 index 8470122..834e778 100644 --- a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_RequestPipeline.lua +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_RequestPipeline.lua @@ -92,7 +92,12 @@ local function processRequest(rawBase64) 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)) + + local cachedErrCode = nil + if not cached.ok then + cachedErrCode = cached.error and cached.error.code + end + audit(id, op, cached.ok, cachedErrCode) return end @@ -114,7 +119,12 @@ local function processRequest(rawBase64) RDB_Idempotency.put(id, response) publishResponse(response) - audit(id, op, dispatchOk, dispatchOk and nil or dataOrCode) + + local errCode = nil + if not dispatchOk then + errCode = dataOrCode + end + audit(id, op, dispatchOk, errCode) end local function onTick() 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 index d60f00e..ba1ec76 100644 --- a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Security.lua +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Security.lua @@ -41,10 +41,12 @@ end local requestTimestamps = {} -function RDB_Security.checkRateLimit() +-- Drops timestamps that have aged out of the current window and returns how +-- many remain. Shared by checkRateLimit (which then appends the new request) +-- and getRateLimitStatus (which only observes). +local function pruneAndCount() local now = getTimestampMs() local windowMs = RDB_Config.get("rateLimitWindowSeconds") * 1000 - local maxRequests = RDB_Config.get("rateLimitMaxRequests") local kept = {} for _, ts in ipairs(requestTimestamps) do @@ -54,10 +56,24 @@ function RDB_Security.checkRateLimit() end requestTimestamps = kept - if #requestTimestamps >= maxRequests then + return #requestTimestamps +end + +function RDB_Security.checkRateLimit() + local maxRequests = RDB_Config.get("rateLimitMaxRequests") + + if pruneAndCount() >= maxRequests then return false, RDB_Constants.ERROR_CODES.RATE_LIMITED, "Too many requests, slow down" end - requestTimestamps[#requestTimestamps + 1] = now + requestTimestamps[#requestTimestamps + 1] = getTimestampMs() return true end + +function RDB_Security.getRateLimitStatus() + return { + currentWindowRequests = pruneAndCount(), + maxRequests = RDB_Config.get("rateLimitMaxRequests"), + windowSeconds = RDB_Config.get("rateLimitWindowSeconds"), + } +end From dd801de28db50cba98f05bcd3418ef09409de146 Mon Sep 17 00:00:00 2001 From: Overlord Date: Thu, 27 Aug 2026 11:57:02 +0200 Subject: [PATCH 2/2] Enhance request pipeline with safe operation dispatch, support for paused server states, and bridge status updates. --- .../RconDataBridge/Ops/RDB_OpBridgeStatus.lua | 13 ++++++- .../RconDataBridge/RDB_RequestPipeline.lua | 35 ++++++++++++++++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpBridgeStatus.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpBridgeStatus.lua index 66a21e8..895ac9c 100644 --- a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpBridgeStatus.lua +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpBridgeStatus.lua @@ -1,18 +1,29 @@ +---@diagnostic disable: need-check-nil + -- Registration deferred to register(), called from RDB_Bootstrap's -- onServerStarted, see RDB_OpWorldGetStats.lua. RDB_OpBridgeStatus = {} +-- Mirrors zombie.GameTime.isGamePaused()'s server-side branch exactly +-- (decompiled: `GameServer.Players.isEmpty() && ServerOptions.instance.pauseEmpty.getValue()`), +-- true precisely when RDB_RequestPipeline would be relying on Events.OnTickEvenPaused +-- instead of Events.OnTick to even see this request. +local function isPaused() + return ServerOptions.instance:getBoolean("PauseEmpty") and getOnlinePlayers():size() == 0 +end + function RDB_OpBridgeStatus.register() RDB_OpRegistry.register("get_bridge_status", { handler = function(_) local bootedAt = RDB_Bootstrap.getBootTimestamp() - local now = getTimestampMs() + local now = getTimestampMs() return true, { version = RDB_Constants.PROTOCOL_VERSION, bootedAt = bootedAt, uptimeSeconds = (now - bootedAt) / 1000, + paused = isPaused(), rateLimit = RDB_Security.getRateLimitStatus(), config = RDB_Config.getAll(), } 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 index 834e778..5ea442c 100644 --- a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_RequestPipeline.lua +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_RequestPipeline.lua @@ -44,6 +44,19 @@ local function audit(id, op, ok, code) RDB_AuditLog.record({ id = id, op = op, ok = ok, code = code }) end +-- If an op handler throws (a bug, a bad deploy, a version mismatch between +-- files), surface it as a fast INTERNAL_ERROR response instead of silently +-- swallowing it. The raw Lua error is logged server-side only, never +-- sent to the client. +local function safeDispatch(op, args) + local ok, a, b, c = pcall(RDB_OpRegistry.dispatch, op, args) + if not ok then + RDB_Log.error("op '" .. tostring(op) .. "' handler threw: " .. tostring(a)) + return false, RDB_Constants.ERROR_CODES.INTERNAL_ERROR, "Unhandled error in operation handler." + end + return a, b, c +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 -- @@ -109,7 +122,7 @@ local function processRequest(rawBase64) return end - local dispatchOk, dataOrCode, message = RDB_OpRegistry.dispatch(op, args) + local dispatchOk, dataOrCode, message = safeDispatch(op, args) local response if dispatchOk then response = buildOkResponse(id, dataOrCode) @@ -148,14 +161,28 @@ 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. +-- Guarded: Events.OnServerStarted fires more than once per boot, +-- and Events.OnTick.Add does not deduplicate identical listeners, +-- so calling this twice would otherwise double-register onTick. +-- +-- Registered on BOTH OnTick and OnTickEvenPaused: decompiling GameWindow's +-- main loop shows these two are mutually exclusive per real engine tick; +-- normal ticks fire OnTick (via IngameState's update path), while a tick +-- where the server is paused (PauseEmpty=true and no players connected; +-- see zombie.GameTime.isGamePaused) fires OnTickEvenPaused instead, never +-- both. Without the second registration, the entire bridge (every op, not +-- just one) goes completely inert on any server running PauseEmpty=true +-- whenever it's empty: no event ever calls onTick, so a request just sits +-- in RconDataBridge_Request forever with no response and no error, and a +-- client sees a silent timeout with nothing to diagnose. function RDB_RequestPipeline.init() if initialized then return end + initialized = true lastSeenRaw = RDB_OptionsRegistry.get(RDB_Constants.OPT.REQUEST) + Events.OnTick.Add(onTick) + Events.OnTickEvenPaused.Add(onTick) end