3 Commits

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_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()

View File

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

View File

@@ -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 --
@@ -92,7 +105,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
@@ -104,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)
@@ -114,7 +132,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()
@@ -138,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

View File

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