Merge branch 'release/v1.1.0'

This commit is contained in:
2026-08-27 11:57:17 +02:00
6 changed files with 158 additions and 10 deletions

View File

@@ -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

View File

@@ -0,0 +1,32 @@
---@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()
return true, {
version = RDB_Constants.PROTOCOL_VERSION,
bootedAt = bootedAt,
uptimeSeconds = (now - bootedAt) / 1000,
paused = isPaused(),
rateLimit = RDB_Security.getRateLimitStatus(),
config = RDB_Config.getAll(),
}
end,
})
end

View File

@@ -12,6 +12,16 @@ require("RconDataBridge.Ops.RDB_OpWorldGetStats")
require("RconDataBridge.Ops.RDB_OpPlayerGet") require("RconDataBridge.Ops.RDB_OpPlayerGet")
require("RconDataBridge.Ops.RDB_OpPlayerList") require("RconDataBridge.Ops.RDB_OpPlayerList")
require("RconDataBridge.Ops.RDB_OpServerSave") 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() local function runSelfTests()
if not RDB_Constants.DEBUG then if not RDB_Constants.DEBUG then
@@ -47,6 +57,8 @@ local function registerOps()
RDB_OpPlayerGet.register() RDB_OpPlayerGet.register()
RDB_OpPlayerList.register() RDB_OpPlayerList.register()
RDB_OpServerSave.register() RDB_OpServerSave.register()
RDB_OpBridgeStatus.register()
RDB_OpAuditLogGet.register()
end end
local function publishProtocolVersion() local function publishProtocolVersion()
@@ -59,6 +71,9 @@ local function publishProtocolVersion()
end end
local function onServerStarted() local function onServerStarted()
if not bootTimestamp then
bootTimestamp = getTimestampMs()
end
runSelfTests() runSelfTests()
RDB_Config.init() RDB_Config.init()
registerBaseOptions() registerBaseOptions()

View File

@@ -43,3 +43,16 @@ function RDB_Config.set(key, value)
store[key] = value store[key] = value
end 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

View File

@@ -44,6 +44,19 @@ local function audit(id, op, ok, code)
RDB_AuditLog.record({ id = id, op = op, ok = ok, code = code }) RDB_AuditLog.record({ id = id, op = op, ok = ok, code = code })
end 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 -- Handles one raw request string end to end. raw is base64: RCON's
-- changeoption command strips every literal double-quote character from -- changeoption command strips every literal double-quote character from
-- every argument (see RESEARCH_LOG.md), so a client can't send raw JSON -- -- every argument (see RESEARCH_LOG.md), so a client can't send raw JSON --
@@ -92,7 +105,12 @@ local function processRequest(rawBase64)
if RDB_Idempotency.has(id) then if RDB_Idempotency.has(id) then
local cached = RDB_Idempotency.get(id) local cached = RDB_Idempotency.get(id)
publishResponse(cached) 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 return
end end
@@ -104,7 +122,7 @@ local function processRequest(rawBase64)
return return
end end
local dispatchOk, dataOrCode, message = RDB_OpRegistry.dispatch(op, args) local dispatchOk, dataOrCode, message = safeDispatch(op, args)
local response local response
if dispatchOk then if dispatchOk then
response = buildOkResponse(id, dataOrCode) response = buildOkResponse(id, dataOrCode)
@@ -114,7 +132,12 @@ local function processRequest(rawBase64)
RDB_Idempotency.put(id, response) RDB_Idempotency.put(id, response)
publishResponse(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 end
local function onTick() local function onTick()
@@ -138,14 +161,28 @@ end
local initialized = false local initialized = false
-- Guarded: Events.OnServerStarted fires more than once per boot (see -- Guarded: Events.OnServerStarted fires more than once per boot,
-- RESEARCH_LOG.md), and Events.OnTick.Add does not deduplicate identical -- and Events.OnTick.Add does not deduplicate identical listeners,
-- listeners, so calling this twice would otherwise double-register onTick. -- 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() function RDB_RequestPipeline.init()
if initialized then if initialized then
return return
end end
initialized = true initialized = true
lastSeenRaw = RDB_OptionsRegistry.get(RDB_Constants.OPT.REQUEST) lastSeenRaw = RDB_OptionsRegistry.get(RDB_Constants.OPT.REQUEST)
Events.OnTick.Add(onTick) Events.OnTick.Add(onTick)
Events.OnTickEvenPaused.Add(onTick)
end end

View File

@@ -41,10 +41,12 @@ end
local requestTimestamps = {} 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 now = getTimestampMs()
local windowMs = RDB_Config.get("rateLimitWindowSeconds") * 1000 local windowMs = RDB_Config.get("rateLimitWindowSeconds") * 1000
local maxRequests = RDB_Config.get("rateLimitMaxRequests")
local kept = {} local kept = {}
for _, ts in ipairs(requestTimestamps) do for _, ts in ipairs(requestTimestamps) do
@@ -54,10 +56,24 @@ function RDB_Security.checkRateLimit()
end end
requestTimestamps = kept 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" return false, RDB_Constants.ERROR_CODES.RATE_LIMITED, "Too many requests, slow down"
end end
requestTimestamps[#requestTimestamps + 1] = now requestTimestamps[#requestTimestamps + 1] = getTimestampMs()
return true return true
end end
function RDB_Security.getRateLimitStatus()
return {
currentWindowRequests = pruneAndCount(),
maxRequests = RDB_Config.get("rateLimitMaxRequests"),
windowSeconds = RDB_Config.get("rateLimitWindowSeconds"),
}
end